diff --git a/system-block-ui/src/rendering/export-pdf.ts b/system-block-ui/src/rendering/export-pdf.ts index 758d94a4..3e82c82c 100644 --- a/system-block-ui/src/rendering/export-pdf.ts +++ b/system-block-ui/src/rendering/export-pdf.ts @@ -1,6 +1,7 @@ import { jsPDF } from "jspdf"; import { svg2pdf } from "svg2pdf.js"; import { downloadBlob } from "./download-blob"; +import { normalizeTextBaselinesForSvg2Pdf } from "./normalize-text-baselines-for-svg2pdf"; export { downloadBlob } from "./download-blob"; @@ -175,6 +176,7 @@ const prepareSheets = (input: SchematicPdfInput): PreparedSheet[] => { throw new TypeError(`Schematic sheet ${index + 1} has no SVG content`); } const element = parseSvg(sheet.svg); + normalizeTextBaselinesForSvg2Pdf(element.querySelectorAll("text, tspan")); const dimensions = getSvgDimensions(element); normalizeSvgViewport(element, dimensions); return { element }; diff --git a/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.test.ts b/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.test.ts new file mode 100644 index 00000000..d2b677f0 --- /dev/null +++ b/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeTextBaselinesForSvg2Pdf } from "./normalize-text-baselines-for-svg2pdf"; + +const createBaselineElement = ( + initialAttributes: Readonly>, +) => { + const attributes = new Map(Object.entries(initialAttributes)); + + return { + attributes, + element: { + getAttribute: (name: string) => attributes.get(name) ?? null, + hasAttribute: (name: string) => attributes.has(name), + setAttribute: (name: string, attributeValue: string) => { + attributes.set(name, attributeValue); + }, + }, + }; +}; + +describe("SVG text baseline compatibility", () => { + test("maps the dominant baseline and calibrates central text", () => { + const centered = createBaselineElement({ + "dominant-baseline": "central", + }); + + normalizeTextBaselinesForSvg2Pdf([centered.element]); + + expect(Object.fromEntries(centered.attributes)).toEqual({ + "dominant-baseline": "central", + "alignment-baseline": "central", + dy: "0.08em", + }); + }); + + test("preserves explicit alignment and positioning", () => { + const explicitlyAligned = createBaselineElement({ + "dominant-baseline": "central", + "alignment-baseline": "hanging", + }); + const explicitlyPositioned = createBaselineElement({ + "dominant-baseline": "central", + dy: "0.2em", + }); + + normalizeTextBaselinesForSvg2Pdf([ + explicitlyAligned.element, + explicitlyPositioned.element, + ]); + + expect(Object.fromEntries(explicitlyAligned.attributes)).toEqual({ + "dominant-baseline": "central", + "alignment-baseline": "hanging", + }); + expect(Object.fromEntries(explicitlyPositioned.attributes)).toEqual({ + "dominant-baseline": "central", + "alignment-baseline": "central", + dy: "0.2em", + }); + }); +}); diff --git a/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.ts b/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.ts new file mode 100644 index 00000000..1f9e3d9c --- /dev/null +++ b/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.ts @@ -0,0 +1,25 @@ +const SVG2PDF_CENTRAL_BASELINE_DY = "0.08em"; + +type SvgTextBaselineElement = Pick< + Element, + "getAttribute" | "hasAttribute" | "setAttribute" +>; + +export const normalizeTextBaselinesForSvg2Pdf = ( + elements: Iterable, +): void => { + for (const element of elements) { + if (element.hasAttribute("alignment-baseline")) continue; + + const dominantBaseline = element.getAttribute("dominant-baseline"); + if (!dominantBaseline) continue; + + element.setAttribute("alignment-baseline", dominantBaseline); + + // jsPDF places its middle baseline slightly above the browser SVG + // central baseline. Preserve author positioning when compensating for it. + if (dominantBaseline === "central" && !element.hasAttribute("dy")) { + element.setAttribute("dy", SVG2PDF_CENTRAL_BASELINE_DY); + } + } +};