tscircuit/ti

ti tscircuit library

Version
1.0.198
License
unset
Stars
0

system-block-ui/src/rendering/export-pdf.ts

import { jsPDF } from "jspdf";
import { svg2pdf } from "svg2pdf.js";
import { downloadBlob } from "./download-blob";

export { downloadBlob } from "./download-blob";

const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const DEFAULT_FILE_NAME = "system-schematic.pdf";
const DEFAULT_ORIENTATION = "landscape";
const PDF_FONT_FAMILY = "LiberationSans";
const PDF_TEXT_ANCHOR_GAP_EM = 0.12;
const PDF_NET_LABEL_BASELINE_SHIFT_EM = 0.06;
const SVG_COLOR_ATTRIBUTES = [
  "color",
  "fill",
  "flood-color",
  "lighting-color",
  "stop-color",
  "stroke",
] as const;
const THREE_CHANNEL_RGBA_PATTERN =
  /rgba\(\s*([^,()]+)\s*,\s*([^,()]+)\s*,\s*([^,()]+)\s*\)/gi;
const SHEET_OVERLAY_MARGIN_MM = 16;
const SHEET_OVERLAY_BASELINE_MM = 16;
const SHEET_OVERLAY_GAP_MM = 4;
const SHEET_OVERLAY_BACKDROP_Y_MM = 11.5;
const SHEET_OVERLAY_BACKDROP_HEIGHT_MM = 5.5;
const SHEET_OVERLAY_BACKDROP_PADDING_X_MM = 1;
const SHEET_OVERLAY_TITLE_FONT_SIZE_PT = 10;
const SHEET_OVERLAY_PAGE_FONT_SIZE_PT = 9;
const SCHEMATIC_BACKGROUND_RGB = [245, 241, 237] as const;
const EMBEDDED_PDF_FONTS = [
  {
    fileName: "LiberationSans-Regular.ttf",
    style: "normal",
    url: new URL(
      "../../node_modules/@docx-editor.dev/fonts/assets/LiberationSans-Regular.ttf",
      import.meta.url,
    ).href,
  },
  {
    fileName: "LiberationSans-Bold.ttf",
    style: "bold",
    url: new URL(
      "../../node_modules/@docx-editor.dev/fonts/assets/LiberationSans-Bold.ttf",
      import.meta.url,
    ).href,
  },
] as const;

/** The evaluator's richer sheet objects are structurally assignable here. */
export interface SchematicPdfSheet {
  svg: string;
  title?: string;
}

export type SchematicPdfInput = string | readonly SchematicPdfSheet[];

export interface SchematicPdfOptions {
  /** Fixed orientation used by every page. Defaults to A4 landscape. */
  orientation?: "portrait" | "landscape";
  format?: string | readonly [number, number];
  /** PDF document metadata title. */
  title?: string;
}

export interface DownloadSchematicPdfOptions extends SchematicPdfOptions {
  fileName?: string;
}

interface SvgDimensions {
  width: number;
  height: number;
}

export interface SchematicPdfPageLayoutRequest {
  pageWidthMm: number;
  pageHeightMm: number;
}

export interface SchematicPdfPageLayout {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface PreparedSheet {
  element: Element;
  title: string;
}

const parseSvg = (svg: string): Element => {
  if (typeof DOMParser === "undefined") {
    throw new Error("PDF export requires a browser DOMParser");
  }

  // XML entities and doctypes are not needed in circuit-to-svg output and can
  // make untrusted XML parsing surprising across browser implementations.
  if (/<!DOCTYPE|<!ENTITY/i.test(svg)) {
    throw new Error("SVG doctypes and entities are not supported");
  }

  const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
  if (parsed.querySelector("parsererror")) {
    throw new Error("Unable to export malformed SVG");
  }

  const root = parsed.documentElement;
  if (root.localName !== "svg" || root.namespaceURI !== SVG_NAMESPACE) {
    throw new Error("PDF export input must have an SVG root element");
  }

  for (const element of [root, ...root.querySelectorAll("*")]) {
    const localName = element.localName.toLowerCase();
    if (
      localName === "script" ||
      localName === "foreignobject" ||
      localName === "iframe" ||
      localName === "object" ||
      localName === "embed"
    ) {
      throw new Error(`Unsafe SVG element <${element.localName}>`);
    }

    for (const attribute of element.attributes) {
      if (
        attribute.localName.toLowerCase().startsWith("on") ||
        /javascript\s*:/i.test(attribute.value)
      ) {
        throw new Error(`Unsafe SVG attribute ${attribute.name}`);
      }
    }
  }

  return root;
};

const parsePositiveNumber = (value: string | null): number | undefined => {
  if (value === null) return undefined;
  const match = value.trim().match(/^([+]?(?:\d+\.?\d*|\.\d+))/);
  if (!match) return undefined;
  const number = Number(match[1]);
  return Number.isFinite(number) && number > 0 ? number : undefined;
};

const getSvgDimensions = (svg: Element): SvgDimensions => {
  const viewBox = svg
    .getAttribute("viewBox")
    ?.trim()
    .split(/[\s,]+/)
    .map(Number);

  if (
    viewBox?.length === 4 &&
    viewBox.every(Number.isFinite) &&
    viewBox[2] > 0 &&
    viewBox[3] > 0
  ) {
    return { width: viewBox[2], height: viewBox[3] };
  }

  return {
    width: parsePositiveNumber(svg.getAttribute("width")) ?? 1200,
    height: parsePositiveNumber(svg.getAttribute("height")) ?? 600,
  };
};

const normalizeSvgViewport = (
  svg: Element,
  dimensions: SvgDimensions,
): void => {
  // circuit-to-svg currently emits width/height without a viewBox. Supplying
  // one lets svg2pdf scale the schematic uniformly into the PDF page.
  if (!svg.hasAttribute("viewBox")) {
    svg.setAttribute("viewBox", `0 0 ${dimensions.width} ${dimensions.height}`);
  }
  svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
};

const normalizeThreeChannelRgba = (value: string): string =>
  value.replace(
    THREE_CHANNEL_RGBA_PATTERN,
    (_match, red: string, green: string, blue: string) =>
      `rgb(${red.trim()}, ${green.trim()}, ${blue.trim()})`,
  );

/** Normalizes browser-valid paint values that svg2pdf does not parse. */
export const normalizeSvgPaintForPdf = (svg: Element): void => {
  for (const element of [svg, ...svg.querySelectorAll("*")]) {
    for (const attribute of SVG_COLOR_ATTRIBUTES) {
      const value = element.getAttribute(attribute);
      if (value === null) continue;

      const normalized = normalizeThreeChannelRgba(value);
      if (normalized !== value) element.setAttribute(attribute, normalized);
    }

    const style = element.getAttribute("style");
    if (style === null) continue;

    const normalizedStyle = normalizeThreeChannelRgba(style);
    if (normalizedStyle !== style) {
      element.setAttribute("style", normalizedStyle);
    }
  }
};

export const normalizeSvgTextForPdf = (svg: Element): void => {
  for (const textElement of svg.querySelectorAll("text, tspan")) {
    textElement.setAttribute("font-family", PDF_FONT_FAMILY);
    const classes = textElement.getAttribute("class")?.split(/\s+/) ?? [];

    // circuit-to-svg adds a background-colored outline around reference
    // designators. svg2pdf paints that halo over the fill, making R/C labels
    // invisible even though their text remains in the PDF content stream.
    if (classes.includes("sch-component-name")) {
      textElement.setAttribute("stroke", "none");
      textElement.removeAttribute("stroke-width");
      textElement.removeAttribute("paint-order");
    }

    // svg2pdf reads alignment-baseline but not dominant-baseline. Copying the
    // SVG baseline keeps labels vertically centered instead of treating their
    // center coordinate as an alphabetic baseline and pushing text upward.
    const dominantBaseline = textElement.getAttribute("dominant-baseline");
    if (dominantBaseline && !textElement.hasAttribute("alignment-baseline")) {
      textElement.setAttribute("alignment-baseline", dominantBaseline);
    }

    // svg2pdf's baseline calculation lands closer to an anchor than browsers
    // do. Move text away from top/bottom anchors without changing its width,
    // and optically center middle-aligned text. Boxed net labels need a smaller
    // correction so their underscores stay clear of the bottom outline.
    if (!textElement.hasAttribute("dy")) {
      const isNetLabel = classes.includes("sch-net-label-text");
      const shiftEm =
        dominantBaseline === "hanging"
          ? PDF_TEXT_ANCHOR_GAP_EM
          : dominantBaseline === "ideographic"
            ? -PDF_TEXT_ANCHOR_GAP_EM
            : dominantBaseline === "central" || dominantBaseline === "middle"
              ? isNetLabel
                ? PDF_NET_LABEL_BASELINE_SHIFT_EM
                : PDF_TEXT_ANCHOR_GAP_EM
              : undefined;
      if (shiftEm !== undefined) {
        textElement.setAttribute("dy", `${shiftEm}em`);
      }
    }

    // Inline CSS overrides SVG presentation attributes. Appending the family
    // keeps text that circuit-to-svg emits with style="font-family: ..." on
    // the same embedded face as every other label.
    const style = textElement.getAttribute("style");
    if (style) {
      textElement.setAttribute(
        "style",
        `${style};font-family:${PDF_FONT_FAMILY}`,
      );
    }
  }
};

const validateFinitePositive = (name: string, value: number): void => {
  if (!Number.isFinite(value) || value <= 0) {
    throw new RangeError(`${name} must be a positive finite number`);
  }
};

/** Uses the full PDF page as the schematic viewport. */
export function calculateSchematicPdfPageLayout({
  pageWidthMm,
  pageHeightMm,
}: SchematicPdfPageLayoutRequest): SchematicPdfPageLayout {
  validateFinitePositive("pageWidthMm", pageWidthMm);
  validateFinitePositive("pageHeightMm", pageHeightMm);

  return {
    x: 0,
    y: 0,
    width: pageWidthMm,
    height: pageHeightMm,
  };
}

const bytesToBase64 = (bytes: Uint8Array): string => {
  if (typeof btoa === "undefined") {
    throw new Error("PDF export requires the browser btoa API");
  }

  let binary = "";
  const chunkSize = 0x8000;
  for (let offset = 0; offset < bytes.length; offset += chunkSize) {
    binary += String.fromCharCode(
      ...bytes.subarray(offset, offset + chunkSize),
    );
  }
  return btoa(binary);
};

const registerPdfFonts = async (pdf: jsPDF): Promise<void> => {
  const fonts = await Promise.all(
    EMBEDDED_PDF_FONTS.map(async (font) => {
      const response = await fetch(font.url);
      if (!response.ok) {
        throw new Error(
          `Unable to load the embedded PDF font ${font.fileName}: HTTP ${response.status}`,
        );
      }
      return { ...font, bytes: new Uint8Array(await response.arrayBuffer()) };
    }),
  );

  for (const font of fonts) {
    pdf.addFileToVFS(font.fileName, bytesToBase64(font.bytes));
    pdf.addFont(font.fileName, PDF_FONT_FAMILY, font.style);
  }

  pdf.setFont(PDF_FONT_FAMILY, "normal");
};

const normalizeSheetTitle = (
  title: string | undefined,
  pageNumber: number,
  pageCount: number,
): string => {
  const normalized = title?.replace(/\s+/g, " ").trim();
  if (normalized) return normalized;
  return pageCount === 1 ? "Schematic" : `Sheet ${pageNumber}`;
};

const prepareSheets = (input: SchematicPdfInput): PreparedSheet[] => {
  const sheets: readonly SchematicPdfSheet[] =
    typeof input === "string" ? [{ svg: input }] : input;
  if (sheets.length === 0) {
    throw new RangeError("At least one schematic sheet is required");
  }

  // Parse every sheet before creating the PDF so invalid later pages never
  // produce a partial download.
  return sheets.map((sheet, index) => {
    if (typeof sheet.svg !== "string" || sheet.svg.trim().length === 0) {
      throw new TypeError(`Schematic sheet ${index + 1} has no SVG content`);
    }
    const element = parseSvg(sheet.svg);
    const dimensions = getSvgDimensions(element);
    normalizeSvgViewport(element, dimensions);
    normalizeSvgPaintForPdf(element);
    normalizeSvgTextForPdf(element);
    return {
      element,
      title: normalizeSheetTitle(sheet.title, index + 1, sheets.length),
    };
  });
};

const fitTitle = (pdf: jsPDF, title: string, maxWidth: number): string => {
  if (pdf.getTextWidth(title) <= maxWidth) return title;

  const suffix = "...";
  let end = title.length;
  while (
    end > 0 &&
    pdf.getTextWidth(`${title.slice(0, end)}${suffix}`) > maxWidth
  ) {
    end -= 1;
  }
  return `${title.slice(0, end).trimEnd()}${suffix}`;
};

/** Draws the sheet name and page count after the SVG so they remain on top. */
export const renderSchematicPdfSheetOverlay = ({
  pdf,
  title,
  pageNumber,
  pageCount,
  pageWidthMm,
}: {
  pdf: jsPDF;
  title: string;
  pageNumber: number;
  pageCount: number;
  pageWidthMm: number;
}): void => {
  const pageLabel = `${pageNumber}/${pageCount}`;

  pdf.setFont(PDF_FONT_FAMILY, "normal");
  pdf.setFontSize(SHEET_OVERLAY_PAGE_FONT_SIZE_PT);
  const pageLabelWidth = pdf.getTextWidth(pageLabel);

  pdf.setFont(PDF_FONT_FAMILY, "bold");
  pdf.setFontSize(SHEET_OVERLAY_TITLE_FONT_SIZE_PT);
  const maxTitleWidth = Math.max(
    1,
    pageWidthMm -
      SHEET_OVERLAY_MARGIN_MM * 2 -
      pageLabelWidth -
      SHEET_OVERLAY_GAP_MM,
  );
  const fittedTitle = fitTitle(pdf, title, maxTitleWidth);
  const fittedTitleWidth = pdf.getTextWidth(fittedTitle);

  pdf.setFillColor(...SCHEMATIC_BACKGROUND_RGB);
  pdf.rect(
    SHEET_OVERLAY_MARGIN_MM - SHEET_OVERLAY_BACKDROP_PADDING_X_MM,
    SHEET_OVERLAY_BACKDROP_Y_MM,
    fittedTitleWidth + SHEET_OVERLAY_BACKDROP_PADDING_X_MM * 2,
    SHEET_OVERLAY_BACKDROP_HEIGHT_MM,
    "F",
  );
  pdf.rect(
    pageWidthMm -
      SHEET_OVERLAY_MARGIN_MM -
      pageLabelWidth -
      SHEET_OVERLAY_BACKDROP_PADDING_X_MM,
    SHEET_OVERLAY_BACKDROP_Y_MM,
    pageLabelWidth + SHEET_OVERLAY_BACKDROP_PADDING_X_MM * 2,
    SHEET_OVERLAY_BACKDROP_HEIGHT_MM,
    "F",
  );

  pdf.setTextColor(45, 55, 72);
  pdf.text(fittedTitle, SHEET_OVERLAY_MARGIN_MM, SHEET_OVERLAY_BASELINE_MM);

  pdf.setFont(PDF_FONT_FAMILY, "normal");
  pdf.setFontSize(SHEET_OVERLAY_PAGE_FONT_SIZE_PT);
  pdf.text(
    pageLabel,
    pageWidthMm - SHEET_OVERLAY_MARGIN_MM,
    SHEET_OVERLAY_BASELINE_MM,
    { align: "right" },
  );
};

/** Converts one or more schematic SVGs into a vector, one-sheet-per-page PDF. */
export async function createSchematicPdfBlob(
  input: SchematicPdfInput,
  options: SchematicPdfOptions = {},
): Promise<Blob> {
  const sheets = prepareSheets(input);

  const format: string | number[] =
    typeof options.format === "string" || options.format === undefined
      ? (options.format ?? "a4")
      : [options.format[0], options.format[1]];
  const orientation = options.orientation ?? DEFAULT_ORIENTATION;
  const pdf = new jsPDF({
    orientation,
    unit: "mm",
    format,
    compress: true,
    putOnlyUsedFonts: true,
  });

  await registerPdfFonts(pdf);

  pdf.setProperties({
    ...(options.title ? { title: options.title } : {}),
    creator: "tscircuit system block UI",
  });

  for (const [index, sheet] of sheets.entries()) {
    if (index > 0) pdf.addPage(format, orientation);

    const pageWidth = pdf.internal.pageSize.getWidth();
    const pageHeight = pdf.internal.pageSize.getHeight();
    const layout = calculateSchematicPdfPageLayout({
      pageWidthMm: pageWidth,
      pageHeightMm: pageHeight,
    });
    await svg2pdf(sheet.element, pdf, {
      x: layout.x,
      y: layout.y,
      width: layout.width,
      height: layout.height,
      loadExternalStyleSheets: false,
    });
    renderSchematicPdfSheetOverlay({
      pdf,
      title: sheet.title,
      pageNumber: index + 1,
      pageCount: sheets.length,
      pageWidthMm: pageWidth,
    });
  }

  return pdf.output("blob");
}

/** Builds and downloads a vector schematic PDF, returning the downloaded Blob. */
export async function downloadSchematicPdf(
  input: SchematicPdfInput,
  options: DownloadSchematicPdfOptions = {},
): Promise<Blob> {
  const blob = await createSchematicPdfBlob(input, options);
  downloadBlob(blob, options.fileName ?? DEFAULT_FILE_NAME);
  return blob;
}

export const exportSchematicPdf = downloadSchematicPdf;