OpenGraphCommand.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import { ReplaceCommand } from "ssg-api/dist/src/step/content/replace/ReplaceCommand"
import { HtmlRR0SsgContext } from "./RR0SsgContext"
import { Canvas, CanvasRenderingContext2D, createCanvas, loadImage } from "canvas"
import fs from "fs"
import path from "path"
import { TimeTextBuilder } from "./time/TimeTextBuilder"
import { RR0ContentStep } from "./RR0ContentStep"
import assert from "assert"

/**
 * Create a preview image for each page sharing.
 */
export class OpenGraphCommand implements ReplaceCommand<HtmlRR0SsgContext> {

  protected num = 0

  constructor(
    protected outDir: string, protected timeFiles: string[], protected baseUrl: string,
    protected timeTextBuilder: TimeTextBuilder,
    protected width: number = 1200, protected height: number = 600
  ) {
  }

  async execute(context: HtmlRR0SsgContext): Promise<void> {
    const title = context.file.title
    if (!title) { // Nothing to write in preview?
      return
    }
    const canvas = createCanvas(this.width, this.height)
    const canvasCtx = canvas.getContext("2d")

    const imageWidthRatio = await this.drawImage(context, canvasCtx)
    this.drawGradient(canvasCtx, imageWidthRatio)

    const margin = 40

    canvasCtx.fillStyle = "#666"
    this.drawText(canvasCtx, title, margin, 70, "400 3em system-ui,sans-serif")

    const infoStr = this.getInfoStr(context)
    canvasCtx.font = "400 1.25em system-ui,sans-serif"
    canvasCtx.fillText(infoStr, margin, this.height - 50)

    this.num++
    const imageUrl = this.writeImageFile(context, canvas)

    const outDoc = context.file.document
    const ogMeta = outDoc.createElement("meta")
    ogMeta.setAttribute("property", "og:image")
    ogMeta.setAttribute("content", imageUrl)
    outDoc.head.append(ogMeta)
    context.file.contents = outDoc.documentElement.outerHTML
  }

  getInfoStr(context: HtmlRR0SsgContext) {
    const authors = context.file.meta.author
    const authorsStr = authors && authors.length > 0 ? authors.join(" & ") : ""

    let timeStr = ""
    const fileName = context.file.name
    if (this.timeFiles.includes(fileName)) {
      timeStr = "Chronologie"
    } else {
      const timeContext = RR0ContentStep.setTimeFromPath(context, fileName)
      if (timeContext) {
        context.time.setYear(timeContext.getYear())
        context.time.setMonth(timeContext.getMonth())
        context.time.setDayOfMonth(timeContext.getDayOfMonth())
        context.time.setHour(undefined)
        context.time.setMinutes(undefined)
        timeStr = this.timeTextBuilder.build(context)
      }
    }

    const copyrightStr = context.file.meta.copyright || "RR0.org"
    let infoStr = authorsStr ? authorsStr : ""
    infoStr = infoStr ? [infoStr, copyrightStr].join(" : ") : copyrightStr
    if (timeStr) {
      if (timeStr === "Chronologie") {
        infoStr = [timeStr, infoStr].join(", ")
      } else {
        infoStr = [infoStr, timeStr].join(", ")
      }
    }
    return infoStr
  }

  /**
   * Draw text on the canvas, with line returns when required.
   *
   * @param canvasCtx
   * @param text The text to write.
   * @param margin
   * @param lineHeight
   * @param font
   * @protected
   */
  protected drawText(canvasCtx: CanvasRenderingContext2D, text: string, margin: number, lineHeight: number,
                     font: string) {
    canvasCtx.font = font
    let lineText = text
    let remainingText = lineText
    let splitPos = text.length
    let line = 0
    let overflow = true
    while (overflow && remainingText.length > 0) {
      const textWidth = canvasCtx.measureText(lineText)
      overflow = textWidth.width > this.width - margin
      if (overflow) {
        splitPos = lineText.lastIndexOf(" ")
        if (splitPos > 0) {
          remainingText = lineText.substring(splitPos).trim()
          lineText = lineText.substring(0, splitPos)
        } else {
          remainingText = ""
        }
      } else {
        canvasCtx.fillText(lineText, margin, 100 + line * lineHeight)
        line++
        overflow = lineText != remainingText
        lineText = remainingText
      }
    }
  }

  /**
   * Draw a left-to-right gradient from white to transparent.
   *
   * @param canvasCtx
   * @param widthRatio
   * @param startColor
   * @param endColor
   * @private
   */
  protected drawGradient(canvasCtx: CanvasRenderingContext2D, widthRatio: number,
                         startColor = "rgba(255, 255, 255, 1)",
                         endColor = "rgba(255, 255, 255, 0)") {
    canvasCtx.beginPath()
    {
      canvasCtx.strokeStyle = "transparent"
      // draw rectablge towards right hand side
      canvasCtx.rect(0, 0, this.width, this.height)
      // create linear gradient
      const grdLinear = canvasCtx.createLinearGradient(0, 0, this.width, 0)
      // Important bit here is to use rgba()
      grdLinear.addColorStop(0, startColor)
      grdLinear.addColorStop(widthRatio, startColor)
      grdLinear.addColorStop(1, endColor)
      // add gradient to rectangle
      canvasCtx.fillStyle = grdLinear
      // step below are pretty much standard to finish drawing an object to canvas
      canvasCtx.fill()
      canvasCtx.stroke()
    }
    canvasCtx.closePath()
  }

  /**
   * Draw a height-scaled image on the right of the canvas.
   *
   * @param context
   * @param canvasCtx
   * @param dy
   * @protected
   */
  protected async drawImage(context: HtmlRR0SsgContext, canvasCtx: CanvasRenderingContext2D, dy = 0) {
    const outDoc = context.file.document
    const docImages = outDoc.documentElement.getElementsByTagName("img")
    let widthRatio = 0.5
    let imageIndex = 0
    if (imageIndex < docImages.length) {
      const firstImage = docImages[0]
      const firstImageSrc = firstImage.getAttribute("src")
      let firstImageUrl: string
      try {
        assert.ok(firstImageSrc, "Undefined image src")
        firstImageUrl = firstImageSrc?.startsWith(this.baseUrl) ? firstImageSrc.substring(
          this.baseUrl.length) : firstImageSrc
        assert.ok(firstImageUrl, "Undefined image url")
        const dir = path.dirname(context.file.name)
        const src = firstImageUrl.startsWith("/") ? firstImageUrl.substring(1) : path.join(dir, firstImageUrl)
        const image = await loadImage(src)
        const heightRatio = this.height / image.height
        const dw = image.width * heightRatio
        const dx = this.width - dw
        widthRatio = dx / this.width
        canvasCtx.drawImage(image, dx, dy, dw, this.height)
      } catch (e) {
        context.error(`Error loading image "${firstImageUrl}", skipping it`)
        imageIndex++ // Try next image
      }
    }
    return widthRatio
  }

  async contentStepEnd() {
    // NOP
  }

  protected writeImageFile(context: HtmlRR0SsgContext, canvas: Canvas) {
    const buffer = canvas.toBuffer("image/png")
    const outputName = context.file.name
    const imageName = "og.png"
    const dir = path.dirname(outputName)
    const imageUrl = path.join("/", dir, imageName)
    const imageOutPath = path.join(this.outDir, imageUrl)
    const imageOutDir = path.dirname(imageOutPath)
    if (!fs.existsSync(imageOutDir)) {
      fs.mkdirSync(imageOutDir, {recursive: true})
    }
    context.debug("Writing OG image", imageOutPath)
    fs.writeFileSync(imageOutPath, buffer)
    return imageUrl
  }
}