diff --git a/REDESIGN_PLAN.md b/REDESIGN_PLAN.md index b5b15b938..c7e92f80d 100644 --- a/REDESIGN_PLAN.md +++ b/REDESIGN_PLAN.md @@ -1503,3 +1503,36 @@ Verification: - New: `accessibility` and `keyboard`. - The full suite passes against the production build: 46 passed, 7 opt-in skipped. +--- + +## 13. Forme engine migration + +Decided 29 Sep 2026: react-pdf is replaced by [Forme](https://www.formepdf.com/) 0.25.0 in a full cutover, and Semantic CSS is ported to it. + +The known 0.25.0 defects are accepted until upstream fixes them: +- Extracted text drops the second letter of ligatures ([#156](https://github.com/danmolitor/forme/issues/156)). This one gets a workaround: ligature features are switched off in the bundled font bytes. +- Arabic, Hindi and Hebrew extracted text is scrambled. +- A link inside part of a paragraph loses its annotation ([#157](https://github.com/danmolitor/forme/issues/157)). + +### Shape + +- **Host primitives.** `packages/pdf/src/forme/primitives.tsx` exports react-pdf-compatible `Document`, `Page`, `View`, `Text`, `Link`, `Image`, `Svg`, `Path` and `StyleSheet`. The `#react-pdf-renderer` import alias points at it, so templates keep their structure. +- **Renderer.** A small `react-reconciler` renderer builds the host tree. Hooks and context keep working, which Forme's own serializer can't do because it calls components as plain functions. +- **Conversion.** The host tree becomes Forme elements. On the way, styles are normalised, `Link` becomes `href`, and `Svg` becomes markup. Nodes tagged `data-resume-node` are registered in `globalThis.__formeSourceMap`, so Forme's layout info yields the page map for click-to-edit, Check and Fit. +- **Rendering.** `@formepdf/core` renders in Node; the browser uses `@formepdf/core/worker` with explicit WASM init. One `renderResume()` returns the PDF bytes, the page map and warnings. +- **Fonts.** The bytes are fetched once and cached. WOFF is inflated to sfnt, and the GSUB `liga`, `clig` and `dlig` features are renamed so the shaper skips them (the #156 workaround). Fallback families become a Forme fallback chain. +- **Rich text.** `react-pdf-html` is replaced by a renderer from normalized HTML to Forme text runs and native lists, which also replaces the react-pdf layout patches for list markers and whitespace. +- **Icons.** Phosphor icons are generated as SVG markup from `@phosphor-icons/react`, replacing `phosphor-icons-react-pdf`. +- **Semantic CSS.** The adapter maps resolved styles to Forme style objects. Properties Forme can't draw become diagnostics in the stylesheet editor, for example `object-fit`, `text-indent`, `vertical-align` and `-resume-min-presence-ahead`. +- **Free-form pages.** They render in two passes: measure the content height, then render at that height. +- **Removed:** `@react-pdf/renderer`, `@react-pdf/hyphenate`, `react-pdf-html`, `phosphor-icons-react-pdf`, and the four react-pdf patches. Hyphenation uses Forme's `hyphens` and `lang`. + +### Phases + +1. Engine core: primitives, reconciler, conversion, fonts, render entry points and the page map, proven on Onyx. +2. Templates and shared primitives: icons, rich text, pictures, page backgrounds and free-form pages, across all 15 templates. +3. Semantic CSS adapter and diagnostics. +4. Consumers: the web preview and exports, the template gallery, the server export and public PDF, and the ATS deep check. Remove react-pdf. +5. Tests: port the render and integration tests to Forme; drop the react-pdf-internal tests. +6. Verification: typecheck, unit tests, e2e, extraction checks and a visual pass across templates. + diff --git a/packages/pdf/package.json b/packages/pdf/package.json index 16cdc8afb..b4892bcf2 100644 --- a/packages/pdf/package.json +++ b/packages/pdf/package.json @@ -11,10 +11,11 @@ "./semantic-legacy": "./src/semantic/legacy-converter.ts", "./semantic-tree": "./src/semantic/tree.ts", "./section-title": "./src/section-title.ts", - "./server": "./src/server.tsx" + "./server": "./src/server.tsx", + "./primitives": "./src/forme/primitives.tsx" }, "imports": { - "#react-pdf-renderer": "@react-pdf/renderer" + "#react-pdf-renderer": "./src/forme/primitives.tsx" }, "scripts": { "typecheck": "tsgo --noEmit", @@ -24,6 +25,8 @@ "test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests" }, "dependencies": { + "@formepdf/core": "0.25.0", + "@formepdf/react": "0.25.0", "@react-pdf/hyphenate": "0.1.0", "@react-pdf/renderer": "^4.9.0", "@reactive-resume/fonts": "workspace:*", @@ -32,16 +35,19 @@ "@reactive-resume/utils": "workspace:*", "cjk-regex": "^3.5.0", "fast-png": "^8.0.0", + "fflate": "^0.8.3", "node-html-parser": "^9.0.4", "phosphor-icons-react-pdf": "^0.1.3", "react": "^19.3.0", - "react-pdf-html": "^2.1.5" + "react-pdf-html": "^2.1.5", + "react-reconciler": "0.34.0" }, "devDependencies": { "@napi-rs/canvas": "1.0.9", "@react-pdf/types": "^2.14.0", "@reactive-resume/config": "workspace:*", "@types/react": "^19.3.0", + "@types/react-reconciler": "^0.33.0", "@typescript/native-preview": "7.0.0-dev.20260707.2", "pdfjs-dist": "6.3.289", "pixelmatch": "^7.2.0", diff --git a/packages/pdf/src/browser.tsx b/packages/pdf/src/browser.tsx index 38458c1f9..c7e4855d3 100644 --- a/packages/pdf/src/browser.tsx +++ b/packages/pdf/src/browser.tsx @@ -3,10 +3,9 @@ import type { Template } from "@reactive-resume/schema/templates"; import type { ResumeRenderOptions } from "./context"; import type { PageMap } from "./page-map"; import type { SectionTitleResolver } from "./section-title"; -import { createElement } from "react"; -import { parseResumeData } from "@reactive-resume/schema/resume/data"; -import { pdf } from "#react-pdf-renderer"; -import { ResumeDocument } from "./document"; +import wasmUrl from "@formepdf/core/pkg-web/forme_bg.wasm?url"; +import * as forme from "@formepdf/core/worker"; +import { renderResume } from "./forme/render"; export type CreateResumePdfBlobOptions = { data: ResumeData; @@ -17,21 +16,10 @@ export type CreateResumePdfBlobOptions = { onPageMap?: ((pageMap: PageMap) => void) | undefined; }; -export const createResumePdfBlob = async ({ - data: input, - template, - renderOptions, - resolveSectionTitle, - onPageMap, -}: CreateResumePdfBlobOptions): Promise => { - const data = parseResumeData(input); - const document = createElement(ResumeDocument, { - data, - template: template ?? data.metadata.template, - ...(renderOptions ? { renderOptions } : {}), - resolveSectionTitle, - onPageMap, - }) as Parameters[0]; - - return await pdf(document).toBlob(); +export const createResumePdfBlob = async ({ onPageMap, ...input }: CreateResumePdfBlobOptions): Promise => { + // The 6.5 MB engine downloads with the first PDF, not with the app. + await forme.init(wasmUrl); + const { pdf, pageMap } = await renderResume(forme, input); + onPageMap?.(pageMap); + return new Blob([pdf as Uint8Array], { type: "application/pdf" }); }; diff --git a/packages/pdf/src/context.tsx b/packages/pdf/src/context.tsx index 0259b20e2..83b4ebb9b 100644 --- a/packages/pdf/src/context.tsx +++ b/packages/pdf/src/context.tsx @@ -1,11 +1,8 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; -import type { Locale } from "@reactive-resume/utils/locale"; import type { ReactNode } from "react"; import type { SectionTitleResolver } from "./section-title"; import { createContext, use, useMemo } from "react"; -import { isCJKLocale, isRTL } from "@reactive-resume/utils/locale"; -import { resumeContentContainsCJK } from "./hooks/use-register-fonts"; -import { createHyphenationCallback } from "./hyphenation"; +import { isRTL } from "@reactive-resume/utils/locale"; export type ResumeRenderOptions = { includeCoverLetterHeader?: boolean; @@ -15,7 +12,6 @@ type RenderContextValue = ResumeData & { resolveSectionTitle?: SectionTitleResolver | undefined; renderOptions: ResumeRenderOptions; rtl: boolean; - hyphenationCallback: ReturnType; }; const RenderContext = createContext(null); @@ -35,18 +31,9 @@ export const RenderProvider = ({ children, }: RenderProviderProps) => { const rtl = isRTL(data.metadata.page.locale); - const hyphenationCallback = useMemo( - () => - createHyphenationCallback({ - locale: data.metadata.page.locale, - automatic: data.metadata.typography.hyphenation === true, - cjk: isCJKLocale(data.metadata.page.locale as Locale) || resumeContentContainsCJK(data), - }), - [data], - ); const contextValue = useMemo( - () => ({ ...data, resolveSectionTitle, renderOptions, rtl, hyphenationCallback }), - [data, resolveSectionTitle, renderOptions, rtl, hyphenationCallback], + () => ({ ...data, resolveSectionTitle, renderOptions, rtl }), + [data, resolveSectionTitle, renderOptions, rtl], ); return {children}; diff --git a/packages/pdf/src/document.tsx b/packages/pdf/src/document.tsx index a569661ca..d5410e156 100644 --- a/packages/pdf/src/document.tsx +++ b/packages/pdf/src/document.tsx @@ -3,14 +3,12 @@ import type { Template } from "@reactive-resume/schema/templates"; import type { Locale } from "@reactive-resume/utils/locale"; import type { ComponentType } from "react"; import type { ResumeRenderOptions } from "./context"; -import type { PageMap } from "./page-map"; import type { SectionTitleResolver } from "./section-title"; import type { ResolvedResumeRuntime } from "./semantic"; import { useMemo } from "react"; import { Document } from "#react-pdf-renderer"; import { RenderProvider } from "./context"; -import { registerFonts, resumeContentContainsCJK, resumeContentScripts } from "./hooks/use-register-fonts"; -import { extractPageMap } from "./page-map"; +import { resolvePdfFonts, resumeContentContainsCJK, resumeContentScripts } from "./hooks/use-register-fonts"; import { SemanticRenderProvider } from "./semantic/context"; import { resolveResumeRuntime, resolveStylesheetMode } from "./semantic/resolve"; import { getTemplatePage } from "./templates"; @@ -33,8 +31,6 @@ type ResumeDocumentProps = { renderOptions?: ResumeRenderOptions | undefined; resolveSectionTitle?: SectionTitleResolver | undefined; semanticRuntime?: ResolvedResumeRuntime | undefined; - /** Receives the rendered page map (header, section and item boxes) after each render. */ - onPageMap?: ((pageMap: PageMap) => void) | undefined; }; const getLayoutPageKey = (page: LayoutPage, pageIndex: number) => @@ -46,20 +42,19 @@ export const ResumeDocument = ({ renderOptions, resolveSectionTitle, semanticRuntime, - onPageMap, }: ResumeDocumentProps) => { const TemplatePageComponent = getTemplatePage(template); const creationDate = useMemo(() => new Date(), []); const hasCjkContent = useMemo(() => resumeContentContainsCJK(data), [data]); const scripts = useMemo(() => resumeContentScripts(data), [data]); - const typography = registerFonts( - data.metadata.typography, - data.metadata.page.locale as Locale, - hasCjkContent, - scripts, - ) as Typography; + const typography = useMemo( + () => + resolvePdfFonts(data.metadata.typography, data.metadata.page.locale as Locale, hasCjkContent, scripts) + .typography as Typography, + [data.metadata.typography, data.metadata.page.locale, hasCjkContent, scripts], + ); - // `registerFonts` widens `fontFamily` to `string | string[]` for CJK + // `resolvePdfFonts` widens `fontFamily` to `string | string[]` for CJK // fallback (#2986); the cast carries that wider runtime value through // `ResumeData` without changing the public schema. const resumeData = useMemo(() => ({ ...data, metadata: { ...data.metadata, typography } }), [data, typography]); @@ -76,20 +71,6 @@ export const ResumeDocument = ({ [headerResumeData, semanticRuntime, stylesheetMode, template], ); const semanticMode = semanticRuntime ? "semantic" : stylesheetMode; - // React PDF calls `onRender` inside its stream handler, so a throw here would fail the render. - const pageMapProps = onPageMap - ? { - onRender: (params: unknown) => { - try { - const layout = (params as { _INTERNAL__LAYOUT__DATA_?: unknown } | undefined)?._INTERNAL__LAYOUT__DATA_; - onPageMap(extractPageMap(layout)); - } catch { - // The page map is an editor aid; a PDF must never fail because of it. - } - }, - } - : {}; - return ( {resumeData.metadata.layout.pages.map((page, index) => ( + String.fromCharCode( + view.getUint8(offset), + view.getUint8(offset + 1), + view.getUint8(offset + 2), + view.getUint8(offset + 3), + ); + +/** WOFF 1.0 → sfnt: inflate each table and lay them out after a fresh table directory. */ +function woffToSfnt(bytes: Uint8Array): Uint8Array { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const flavor = view.getUint32(4); + const numTables = view.getUint16(12); + + const tables: { tag: number; checksum: number; data: Uint8Array }[] = []; + for (let index = 0; index < numTables; index++) { + const entry = 44 + index * 20; + const tag = view.getUint32(entry); + const offset = view.getUint32(entry + 4); + const compLength = view.getUint32(entry + 8); + const origLength = view.getUint32(entry + 12); + const checksum = view.getUint32(entry + 16); + const stored = bytes.subarray(offset, offset + compLength); + const data = compLength < origLength ? unzlibSync(stored) : stored; + tables.push({ tag, checksum, data }); + } + + const headerLength = 12 + numTables * 16; + const padded = (length: number) => (length + 3) & ~3; + const total = tables.reduce((sum, table) => sum + padded(table.data.length), headerLength); + const out = new Uint8Array(total); + const outView = new DataView(out.buffer); + + let searchRange = 1; + let entrySelector = 0; + while (searchRange * 2 <= numTables) { + searchRange *= 2; + entrySelector++; + } + outView.setUint32(0, flavor); + outView.setUint16(4, numTables); + outView.setUint16(6, searchRange * 16); + outView.setUint16(8, entrySelector); + outView.setUint16(10, numTables * 16 - searchRange * 16); + + let dataOffset = headerLength; + tables.forEach((table, index) => { + const record = 12 + index * 16; + outView.setUint32(record, table.tag); + outView.setUint32(record + 4, table.checksum); + outView.setUint32(record + 8, dataOffset); + outView.setUint32(record + 12, table.data.length); + out.set(table.data, dataOffset); + dataOffset += padded(table.data.length); + }); + return out; +} + +/** Renames the discretionary ligature features in GSUB, in place. Returns how many it renamed. */ +export function disableLigatures(sfnt: Uint8Array): number { + const view = new DataView(sfnt.buffer, sfnt.byteOffset, sfnt.byteLength); + const numTables = view.getUint16(4); + for (let index = 0; index < numTables; index++) { + const record = 12 + index * 16; + if (tagAt(view, record) !== "GSUB") continue; + const gsub = view.getUint32(record + 8); + const featureList = gsub + view.getUint16(gsub + 6); + const featureCount = view.getUint16(featureList); + let renamed = 0; + for (let feature = 0; feature < featureCount; feature++) { + const tag = featureList + 2 + feature * 6; + if (!LIGATURE_FEATURES.has(tagAt(view, tag))) continue; + sfnt.set(DISABLED_TAG, tag); + renamed++; + } + return renamed; + } + return 0; +} + +/** Font file bytes ready for Forme. Formats it can't patch (WOFF2, collections) pass through unchanged. */ +export function prepareFontBytes(bytes: Uint8Array): Uint8Array { + if (bytes.byteLength < 12) return bytes; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const signature = view.getUint32(0); + const sfnt = signature === WOFF ? woffToSfnt(bytes) : bytes.slice(); + const flavor = new DataView(sfnt.buffer, sfnt.byteOffset, sfnt.byteLength).getUint32(0); + // TrueType (0x00010000, "true") and CFF ("OTTO") share the table directory layout. + if (flavor === 0x00010000 || flavor === 0x74727565 || flavor === 0x4f54544f) disableLigatures(sfnt); + return sfnt; +} diff --git a/packages/pdf/src/forme/fonts.ts b/packages/pdf/src/forme/fonts.ts new file mode 100644 index 000000000..99ff92274 --- /dev/null +++ b/packages/pdf/src/forme/fonts.ts @@ -0,0 +1,43 @@ +import type { FormeFont } from "@formepdf/react"; +import type { PdfFontRequest } from "../hooks/use-register-fonts"; +import { prepareFontBytes } from "./font-bytes"; + +// One download per font file for the life of the page or server process. +const cache = new Map>(); + +const load = (src: string) => { + let bytes = cache.get(src); + if (!bytes) { + bytes = fetch(src) + .then((response) => { + if (!response.ok) throw new Error(`Font ${src}: HTTP ${response.status}`); + return response.arrayBuffer(); + }) + .then((buffer) => prepareFontBytes(new Uint8Array(buffer))); + // A failed download may succeed next time. + bytes.catch(() => cache.delete(src)); + cache.set(src, bytes); + } + return bytes; +}; + +/** + * The font faces a document needs, as bytes Forme can embed. A face that fails to load is left out, and Forme falls + * back to its built-in fonts for that text, as react-pdf did. + */ +export async function loadFonts( + requests: readonly PdfFontRequest[], +): Promise<{ fonts: FormeFont[]; warnings: string[] }> { + const warnings: string[] = []; + const loaded = await Promise.all( + requests.map(async (request): Promise => { + try { + return { family: request.family, weight: request.weight, italic: request.italic, src: await load(request.src) }; + } catch (error) { + warnings.push(error instanceof Error ? error.message : `Font ${request.src} could not be loaded.`); + return null; + } + }), + ); + return { fonts: loaded.filter((font): font is FormeFont => font !== null), warnings }; +} diff --git a/packages/pdf/src/forme/icons.tsx b/packages/pdf/src/forme/icons.tsx new file mode 100644 index 000000000..e429ab4da --- /dev/null +++ b/packages/pdf/src/forme/icons.tsx @@ -0,0 +1,70 @@ +import type iconData from "./phosphor-icons.json"; +import type { StyleProp } from "./style-types"; +import { Svg } from "./primitives"; +import { flattenStyle, toColor } from "./style"; + +/** A Phosphor icon name, e.g. "address-book". */ +export type IconName = keyof typeof iconData; + +let icons: Record | undefined; +let loading: Promise | undefined; + +/** + * Loads the icon drawings (generated by `pnpm --filter @reactive-resume/tooling icons:pdf`). They're 190 KB + * gzipped, so they arrive with the first render rather than with the app. + */ +export const loadIcons = () => + (loading ??= import("./phosphor-icons.json").then((module) => { + icons = module.default as Record; + })); + +export const isIconName = (name: string): name is IconName => icons === undefined || name in icons; + +type PhosphorIconProps = { + name: IconName | string; + size?: number | string | undefined; + color?: string | undefined; + opacity?: number | string | undefined; + /** "none" hides the icon, as on react-pdf's SVG. */ + display?: "none" | "flex" | undefined; + style?: StyleProp | undefined; + weight?: "regular" | undefined; +}; + +/** + * A regular-weight Phosphor icon, drawn as vector paths. Same props as `phosphor-icons-react-pdf`'s `Icon`; an + * unknown name draws nothing. + */ +export function PhosphorIcon({ name, size = 16, color = "currentColor", opacity, display, style }: PhosphorIconProps) { + const markup = icons?.[name]; + if (!markup || display === "none") return null; + // "currentColor" means the colour the template set on the icon's style, as in react-pdf. + const { opacity: styleOpacity, ...flat } = flattenStyle(style); + // Forme 0.25 lets an SVG's opacity leak into whatever it draws next, so it goes into the paint instead. + const alpha = Number(opacity ?? styleOpacity ?? 1); + const paint = svgPaint( + color === "currentColor" ? (flat.color ?? "#000000") : color, + Number.isFinite(alpha) ? alpha : 1, + ); + return ( + + ); +} + +/** SVG fill attributes for a CSS colour: hex, with rgba's alpha times `opacity` as `fill-opacity`. */ +function svgPaint(color: string, opacity: number) { + const parsed = toColor(color) ?? "#000000"; + const match = /^rgba?\(([^)]*)\)$/i.exec(parsed); + const [r = 0, g = 0, b = 0, a = 1] = match?.[1] ? match[1].split(/\s*,\s*/).map(Number) : []; + const hex = match + ? `#${[r, g, b].map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}` + : parsed; + const alpha = a * opacity; + return alpha < 1 ? `fill="${hex}" fill-opacity="${alpha}"` : `fill="${hex}"`; +} diff --git a/packages/pdf/src/forme/images.ts b/packages/pdf/src/forme/images.ts new file mode 100644 index 000000000..8e6b3ad5f --- /dev/null +++ b/packages/pdf/src/forme/images.ts @@ -0,0 +1,97 @@ +import type { HostNode } from "./reconciler"; +import { HOST } from "./primitives"; + +/** A picture ready to embed: its bytes as a data URI and its size in pixels, for `objectFit`. */ +export type LoadedImage = { src: string; width: number; height: number }; + +const MIME_TYPES = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" } as const; + +const u16be = (bytes: Uint8Array, at: number) => ((bytes[at] ?? 0) << 8) | (bytes[at + 1] ?? 0); +const u16le = (bytes: Uint8Array, at: number) => (bytes[at] ?? 0) | ((bytes[at + 1] ?? 0) << 8); +const u24le = (bytes: Uint8Array, at: number) => u16le(bytes, at) | ((bytes[at + 2] ?? 0) << 16); +const u32be = (bytes: Uint8Array, at: number) => u16be(bytes, at) * 65536 + u16be(bytes, at + 2); +const ascii = (bytes: Uint8Array, at: number, length: number) => + String.fromCharCode(...bytes.subarray(at, at + length)); + +/** Format and pixel size of a PNG, JPEG or WebP, the formats Forme draws; undefined for anything else. */ +export function readImageHeader( + bytes: Uint8Array, +): { format: keyof typeof MIME_TYPES; width: number; height: number } | undefined { + if (bytes[0] === 0x89 && ascii(bytes, 1, 3) === "PNG") + return { format: "png", width: u32be(bytes, 16), height: u32be(bytes, 20) }; + + if (bytes[0] === 0xff && bytes[1] === 0xd8) { + // Walk the segments to the first start-of-frame marker, which holds the size. + let at = 2; + while (at + 9 < bytes.length) { + if (bytes[at] !== 0xff) return undefined; + const marker = bytes[at + 1] ?? 0; + const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; + if (isFrame) return { format: "jpeg", width: u16be(bytes, at + 7), height: u16be(bytes, at + 5) }; + at += 2 + u16be(bytes, at + 2); + } + return undefined; + } + + if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") { + const chunk = ascii(bytes, 12, 4); + if (chunk === "VP8 ") + return { format: "webp", width: u16le(bytes, 26) & 0x3fff, height: u16le(bytes, 28) & 0x3fff }; + if (chunk === "VP8L") { + const bits = (bytes[21] ?? 0) | ((bytes[22] ?? 0) << 8) | ((bytes[23] ?? 0) << 16) | ((bytes[24] ?? 0) << 24); + return { format: "webp", width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; + } + if (chunk === "VP8X") return { format: "webp", width: u24le(bytes, 24) + 1, height: u24le(bytes, 27) + 1 }; + } + return undefined; +} + +const toBase64 = (bytes: Uint8Array) => { + let binary = ""; + for (let at = 0; at < bytes.length; at += 0x8000) binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000)); + return btoa(binary); +}; + +/** Every picture source in the rendered tree. */ +export function imageSources(tree: HostNode[]): string[] { + const sources = new Set(); + const visit = (node: HostNode) => { + if ("text" in node) return; + if (node.type === HOST.image) { + const { src } = node.props; + const url = typeof src === "string" ? src : (src as { uri?: unknown } | undefined)?.uri; + if (typeof url === "string" && url.length > 0) sources.add(url); + } + node.children.forEach(visit); + }; + tree.forEach(visit); + return [...sources]; +} + +/** + * Downloads the pictures once, before layout. Forme would fetch them itself, but a failed download there fails the + * whole document; here the picture is left out with a warning, as react-pdf did. + */ +export async function loadImages(sources: readonly string[]) { + const images = new Map(); + const warnings: string[] = []; + await Promise.all( + sources.map(async (source) => { + try { + const response = await fetch(source); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const bytes = new Uint8Array(await response.arrayBuffer()); + const header = readImageHeader(bytes); + if (!header) throw new Error("not a PNG, JPEG or WebP image"); + images.set(source, { + src: `data:${MIME_TYPES[header.format]};base64,${toBase64(bytes)}`, + width: header.width, + height: header.height, + }); + } catch (error) { + warnings.push(`Picture ${source.slice(0, 80)}: ${error instanceof Error ? error.message : String(error)}`); + } + }), + ); + return { images, warnings }; +} diff --git a/packages/pdf/src/forme/phosphor-icons.json b/packages/pdf/src/forme/phosphor-icons.json new file mode 100644 index 000000000..b1aa6c396 --- /dev/null +++ b/packages/pdf/src/forme/phosphor-icons.json @@ -0,0 +1,1514 @@ +{ + "acorn": "", + "address-book": "", + "address-book-tabs": "", + "air-traffic-control": "", + "airplane": "", + "airplane-in-flight": "", + "airplane-landing": "", + "airplane-takeoff": "", + "airplane-taxiing": "", + "airplane-tilt": "", + "airplay": "", + "alarm": "", + "alien": "", + "align-bottom": "", + "align-bottom-simple": "", + "align-center-horizontal": "", + "align-center-horizontal-simple": "", + "align-center-vertical": "", + "align-center-vertical-simple": "", + "align-left": "", + "align-left-simple": "", + "align-right": "", + "align-right-simple": "", + "align-top": "", + "align-top-simple": "", + "amazon-logo": "", + "ambulance": "", + "anchor": "", + "anchor-simple": "", + "android-logo": "", + "angle": "", + "angular-logo": "", + "aperture": "", + "app-store-logo": "", + "app-window": "", + "apple-logo": "", + "apple-podcasts-logo": "", + "approximate-equals": "", + "archive": "", + "armchair": "", + "arrow-arc-left": "", + "arrow-arc-right": "", + "arrow-bend-double-up-left": "", + "arrow-bend-double-up-right": "", + "arrow-bend-down-left": "", + "arrow-bend-down-right": "", + "arrow-bend-left-down": "", + "arrow-bend-left-up": "", + "arrow-bend-right-down": "", + "arrow-bend-right-up": "", + "arrow-bend-up-left": "", + "arrow-bend-up-right": "", + "arrow-circle-down": "", + "arrow-circle-down-left": "", + "arrow-circle-down-right": "", + "arrow-circle-left": "", + "arrow-circle-right": "", + "arrow-circle-up": "", + "arrow-circle-up-left": "", + "arrow-circle-up-right": "", + "arrow-clockwise": "", + "arrow-counter-clockwise": "", + "arrow-down": "", + "arrow-down-left": "", + "arrow-down-right": "", + "arrow-elbow-down-left": "", + "arrow-elbow-down-right": "", + "arrow-elbow-left": "", + "arrow-elbow-left-down": "", + "arrow-elbow-left-up": "", + "arrow-elbow-right": "", + "arrow-elbow-right-down": "", + "arrow-elbow-right-up": "", + "arrow-elbow-up-left": "", + "arrow-elbow-up-right": "", + "arrow-fat-down": "", + "arrow-fat-left": "", + "arrow-fat-line-down": "", + "arrow-fat-line-left": "", + "arrow-fat-line-right": "", + "arrow-fat-line-up": "", + "arrow-fat-lines-down": "", + "arrow-fat-lines-left": "", + "arrow-fat-lines-right": "", + "arrow-fat-lines-up": "", + "arrow-fat-right": "", + "arrow-fat-up": "", + "arrow-left": "", + "arrow-line-down": "", + "arrow-line-down-left": "", + "arrow-line-down-right": "", + "arrow-line-left": "", + "arrow-line-right": "", + "arrow-line-up": "", + "arrow-line-up-left": "", + "arrow-line-up-right": "", + "arrow-right": "", + "arrow-square-down": "", + "arrow-square-down-left": "", + "arrow-square-down-right": "", + "arrow-square-in": "", + "arrow-square-left": "", + "arrow-square-out": "", + "arrow-square-right": "", + "arrow-square-up": "", + "arrow-square-up-left": "", + "arrow-square-up-right": "", + "arrow-u-down-left": "", + "arrow-u-down-right": "", + "arrow-u-left-down": "", + "arrow-u-left-up": "", + "arrow-u-right-down": "", + "arrow-u-right-up": "", + "arrow-u-up-left": "", + "arrow-u-up-right": "", + "arrow-up": "", + "arrow-up-left": "", + "arrow-up-right": "", + "arrows-clockwise": "", + "arrows-counter-clockwise": "", + "arrows-down-up": "", + "arrows-horizontal": "", + "arrows-in": "", + "arrows-in-cardinal": "", + "arrows-in-line-horizontal": "", + "arrows-in-line-vertical": "", + "arrows-in-simple": "", + "arrows-left-right": "", + "arrows-merge": "", + "arrows-out": "", + "arrows-out-cardinal": "", + "arrows-out-line-horizontal": "", + "arrows-out-line-vertical": "", + "arrows-out-simple": "", + "arrows-split": "", + "arrows-vertical": "", + "article": "", + "article-medium": "", + "article-ny-times": "", + "asclepius": "", + "asterisk": "", + "asterisk-simple": "", + "at": "", + "atom": "", + "avocado": "", + "axe": "", + "baby": "", + "baby-carriage": "", + "backpack": "", + "backspace": "", + "bag": "", + "bag-simple": "", + "balloon": "", + "bandaids": "", + "bank": "", + "barbell": "", + "barcode": "", + "barn": "", + "barricade": "", + "baseball": "", + "baseball-cap": "", + "baseball-helmet": "", + "basket": "", + "basketball": "", + "bathtub": "", + "battery-charging": "", + "battery-charging-vertical": "", + "battery-empty": "", + "battery-full": "", + "battery-high": "", + "battery-low": "", + "battery-medium": "", + "battery-plus": "", + "battery-plus-vertical": "", + "battery-vertical-empty": "", + "battery-vertical-full": "", + "battery-vertical-high": "", + "battery-vertical-low": "", + "battery-vertical-medium": "", + "battery-warning": "", + "battery-warning-vertical": "", + "beach-ball": "", + "beanie": "", + "bed": "", + "beer-bottle": "", + "beer-stein": "", + "behance-logo": "", + "bell": "", + "bell-ringing": "", + "bell-simple": "", + "bell-simple-ringing": "", + "bell-simple-slash": "", + "bell-simple-z": "", + "bell-slash": "", + "bell-z": "", + "belt": "", + "bezier-curve": "", + "bicycle": "", + "binary": "", + "binoculars": "", + "biohazard": "", + "bird": "", + "blueprint": "", + "bluetooth": "", + "bluetooth-connected": "", + "bluetooth-slash": "", + "bluetooth-x": "", + "boat": "", + "bomb": "", + "bone": "", + "book": "", + "book-bookmark": "", + "book-open": "", + "book-open-text": "", + "book-open-user": "", + "bookmark": "", + "bookmark-simple": "", + "bookmarks": "", + "bookmarks-simple": "", + "books": "", + "boot": "", + "boules": "", + "bounding-box": "", + "bowl-food": "", + "bowl-steam": "", + "bowling-ball": "", + "box-arrow-down": "", + "box-arrow-up": "", + "boxing-glove": "", + "brackets-angle": "", + "brackets-curly": "", + "brackets-round": "", + "brackets-square": "", + "brain": "", + "brandy": "", + "bread": "", + "bridge": "", + "briefcase": "", + "briefcase-metal": "", + "broadcast": "", + "broom": "", + "browser": "", + "browsers": "", + "bug": "", + "bug-beetle": "", + "bug-droid": "", + "building": "", + "building-apartment": "", + "building-office": "", + "buildings": "", + "bulldozer": "", + "bus": "", + "butterfly": "", + "cable-car": "", + "cactus": "", + "cake": "", + "calculator": "", + "calendar": "", + "calendar-blank": "", + "calendar-check": "", + "calendar-dot": "", + "calendar-dots": "", + "calendar-heart": "", + "calendar-minus": "", + "calendar-plus": "", + "calendar-slash": "", + "calendar-star": "", + "calendar-x": "", + "call-bell": "", + "camera": "", + "camera-plus": "", + "camera-rotate": "", + "camera-slash": "", + "campfire": "", + "car": "", + "car-battery": "", + "car-profile": "", + "car-simple": "", + "cardholder": "", + "cards": "", + "cards-three": "", + "caret-circle-double-down": "", + "caret-circle-double-left": "", + "caret-circle-double-right": "", + "caret-circle-double-up": "", + "caret-circle-down": "", + "caret-circle-left": "", + "caret-circle-right": "", + "caret-circle-up": "", + "caret-circle-up-down": "", + "caret-double-down": "", + "caret-double-left": "", + "caret-double-right": "", + "caret-double-up": "", + "caret-down": "", + "caret-left": "", + "caret-line-down": "", + "caret-line-left": "", + "caret-line-right": "", + "caret-line-up": "", + "caret-right": "", + "caret-up": "", + "caret-up-down": "", + "carrot": "", + "cash-register": "", + "cassette-tape": "", + "castle-turret": "", + "cat": "", + "cell-signal-full": "", + "cell-signal-high": "", + "cell-signal-low": "", + "cell-signal-medium": "", + "cell-signal-none": "", + "cell-signal-slash": "", + "cell-signal-x": "", + "cell-tower": "", + "certificate": "", + "chair": "", + "chalkboard": "", + "chalkboard-simple": "", + "chalkboard-teacher": "", + "champagne": "", + "charging-station": "", + "chart-bar": "", + "chart-bar-horizontal": "", + "chart-donut": "", + "chart-line": "", + "chart-line-down": "", + "chart-line-up": "", + "chart-pie": "", + "chart-pie-slice": "", + "chart-polar": "", + "chart-scatter": "", + "chat": "", + "chat-centered": "", + "chat-centered-dots": "", + "chat-centered-slash": "", + "chat-centered-text": "", + "chat-circle": "", + "chat-circle-dots": "", + "chat-circle-slash": "", + "chat-circle-text": "", + "chat-dots": "", + "chat-slash": "", + "chat-teardrop": "", + "chat-teardrop-dots": "", + "chat-teardrop-slash": "", + "chat-teardrop-text": "", + "chat-text": "", + "chats": "", + "chats-circle": "", + "chats-teardrop": "", + "check": "", + "check-circle": "", + "check-fat": "", + "check-square": "", + "check-square-offset": "", + "checkerboard": "", + "checks": "", + "cheers": "", + "cheese": "", + "chef-hat": "", + "cherries": "", + "church": "", + "cigarette": "", + "cigarette-slash": "", + "circle": "", + "circle-dashed": "", + "circle-half": "", + "circle-half-tilt": "", + "circle-notch": "", + "circles-four": "", + "circles-three": "", + "circles-three-plus": "", + "circuitry": "", + "city": "", + "clipboard": "", + "clipboard-text": "", + "clock": "", + "clock-afternoon": "", + "clock-clockwise": "", + "clock-countdown": "", + "clock-counter-clockwise": "", + "clock-user": "", + "closed-captioning": "", + "cloud": "", + "cloud-arrow-down": "", + "cloud-arrow-up": "", + "cloud-check": "", + "cloud-fog": "", + "cloud-lightning": "", + "cloud-moon": "", + "cloud-rain": "", + "cloud-slash": "", + "cloud-snow": "", + "cloud-sun": "", + "cloud-warning": "", + "cloud-x": "", + "clover": "", + "club": "", + "coat-hanger": "", + "coda-logo": "", + "code": "", + "code-block": "", + "code-simple": "", + "codepen-logo": "", + "codesandbox-logo": "", + "coffee": "", + "coffee-bean": "", + "coin": "", + "coin-vertical": "", + "coins": "", + "columns": "", + "columns-plus-left": "", + "columns-plus-right": "", + "command": "", + "compass": "", + "compass-rose": "", + "compass-tool": "", + "computer-tower": "", + "confetti": "", + "contactless-payment": "", + "control": "", + "cookie": "", + "cooking-pot": "", + "copy": "", + "copy-simple": "", + "copyleft": "", + "copyright": "", + "corners-in": "", + "corners-out": "", + "couch": "", + "court-basketball": "", + "cow": "", + "cowboy-hat": "", + "cpu": "", + "crane": "", + "crane-tower": "", + "credit-card": "", + "cricket": "", + "crop": "", + "cross": "", + "crosshair": "", + "crosshair-simple": "", + "crown": "", + "crown-cross": "", + "crown-simple": "", + "cube": "", + "cube-focus": "", + "cube-transparent": "", + "currency-btc": "", + "currency-circle-dollar": "", + "currency-cny": "", + "currency-dollar": "", + "currency-dollar-simple": "", + "currency-eth": "", + "currency-eur": "", + "currency-gbp": "", + "currency-inr": "", + "currency-jpy": "", + "currency-krw": "", + "currency-kzt": "", + "currency-ngn": "", + "currency-rub": "", + "cursor": "", + "cursor-click": "", + "cursor-text": "", + "cylinder": "", + "database": "", + "desk": "", + "desktop": "", + "desktop-tower": "", + "detective": "", + "dev-to-logo": "", + "device-mobile": "", + "device-mobile-camera": "", + "device-mobile-slash": "", + "device-mobile-speaker": "", + "device-rotate": "", + "device-tablet": "", + "device-tablet-camera": "", + "device-tablet-speaker": "", + "devices": "", + "diamond": "", + "diamonds-four": "", + "dice-five": "", + "dice-four": "", + "dice-one": "", + "dice-six": "", + "dice-three": "", + "dice-two": "", + "disc": "", + "disco-ball": "", + "discord-logo": "", + "divide": "", + "dna": "", + "dog": "", + "door": "", + "door-open": "", + "dot": "", + "dot-outline": "", + "dots-nine": "", + "dots-six": "", + "dots-six-vertical": "", + "dots-three": "", + "dots-three-circle": "", + "dots-three-circle-vertical": "", + "dots-three-outline": "", + "dots-three-outline-vertical": "", + "dots-three-vertical": "", + "download": "", + "download-simple": "", + "dress": "", + "dresser": "", + "dribbble-logo": "", + "drone": "", + "drop": "", + "drop-half": "", + "drop-half-bottom": "", + "drop-simple": "", + "drop-slash": "", + "dropbox-logo": "", + "ear": "", + "ear-slash": "", + "egg": "", + "egg-crack": "", + "eject": "", + "eject-simple": "", + "elevator": "", + "empty": "", + "engine": "", + "envelope": "", + "envelope-open": "", + "envelope-simple": "", + "envelope-simple-open": "", + "equalizer": "", + "equals": "", + "eraser": "", + "escalator-down": "", + "escalator-up": "", + "exam": "", + "exclamation-mark": "", + "exclude": "", + "exclude-square": "", + "export": "", + "eye": "", + "eye-closed": "", + "eye-slash": "", + "eyedropper": "", + "eyedropper-sample": "", + "eyeglasses": "", + "eyes": "", + "face-mask": "", + "facebook-logo": "", + "factory": "", + "faders": "", + "faders-horizontal": "", + "fallout-shelter": "", + "fan": "", + "farm": "", + "fast-forward": "", + "fast-forward-circle": "", + "feather": "", + "fediverse-logo": "", + "figma-logo": "", + "file": "", + "file-archive": "", + "file-arrow-down": "", + "file-arrow-up": "", + "file-audio": "", + "file-c": "", + "file-c-sharp": "", + "file-cloud": "", + "file-code": "", + "file-cpp": "", + "file-css": "", + "file-csv": "", + "file-dashed": "", + "file-doc": "", + "file-html": "", + "file-image": "", + "file-ini": "", + "file-jpg": "", + "file-js": "", + "file-jsx": "", + "file-lock": "", + "file-magnifying-glass": "", + "file-md": "", + "file-minus": "", + "file-pdf": "", + "file-plus": "", + "file-png": "", + "file-ppt": "", + "file-py": "", + "file-rs": "", + "file-sql": "", + "file-svg": "", + "file-text": "", + "file-ts": "", + "file-tsx": "", + "file-txt": "", + "file-video": "", + "file-vue": "", + "file-x": "", + "file-xls": "", + "file-zip": "", + "files": "", + "film-reel": "", + "film-script": "", + "film-slate": "", + "film-strip": "", + "fingerprint": "", + "fingerprint-simple": "", + "finn-the-human": "", + "fire": "", + "fire-extinguisher": "", + "fire-simple": "", + "fire-truck": "", + "first-aid": "", + "first-aid-kit": "", + "fish": "", + "fish-simple": "", + "flag": "", + "flag-banner": "", + "flag-banner-fold": "", + "flag-checkered": "", + "flag-pennant": "", + "flame": "", + "flashlight": "", + "flask": "", + "flip-horizontal": "", + "flip-vertical": "", + "floppy-disk": "", + "floppy-disk-back": "", + "flow-arrow": "", + "flower": "", + "flower-lotus": "", + "flower-tulip": "", + "flying-saucer": "", + "folder": "", + "folder-dashed": "", + "folder-lock": "", + "folder-minus": "", + "folder-open": "", + "folder-plus": "", + "folder-simple": "", + "folder-simple-dashed": "", + "folder-simple-lock": "", + "folder-simple-minus": "", + "folder-simple-plus": "", + "folder-simple-star": "", + "folder-simple-user": "", + "folder-star": "", + "folder-user": "", + "folders": "", + "football": "", + "football-helmet": "", + "footprints": "", + "fork-knife": "", + "four-k": "", + "frame-corners": "", + "framer-logo": "", + "function": "", + "funnel": "", + "funnel-simple": "", + "funnel-simple-x": "", + "funnel-x": "", + "game-controller": "", + "garage": "", + "gas-can": "", + "gas-pump": "", + "gauge": "", + "gavel": "", + "gear": "", + "gear-fine": "", + "gear-six": "", + "gender-female": "", + "gender-intersex": "", + "gender-male": "", + "gender-neuter": "", + "gender-nonbinary": "", + "gender-transgender": "", + "ghost": "", + "gif": "", + "gift": "", + "git-branch": "", + "git-commit": "", + "git-diff": "", + "git-fork": "", + "git-merge": "", + "git-pull-request": "", + "github-logo": "", + "gitlab-logo": "", + "gitlab-logo-simple": "", + "globe": "", + "globe-hemisphere-east": "", + "globe-hemisphere-west": "", + "globe-simple": "", + "globe-simple-x": "", + "globe-stand": "", + "globe-x": "", + "goggles": "", + "golf": "", + "goodreads-logo": "", + "google-cardboard-logo": "", + "google-chrome-logo": "", + "google-drive-logo": "", + "google-logo": "", + "google-photos-logo": "", + "google-play-logo": "", + "google-podcasts-logo": "", + "gps": "", + "gps-fix": "", + "gps-slash": "", + "gradient": "", + "graduation-cap": "", + "grains": "", + "grains-slash": "", + "graph": "", + "graphics-card": "", + "greater-than": "", + "greater-than-or-equal": "", + "grid-four": "", + "grid-nine": "", + "guitar": "", + "hair-dryer": "", + "hamburger": "", + "hammer": "", + "hand": "", + "hand-arrow-down": "", + "hand-arrow-up": "", + "hand-coins": "", + "hand-deposit": "", + "hand-eye": "", + "hand-fist": "", + "hand-grabbing": "", + "hand-heart": "", + "hand-palm": "", + "hand-peace": "", + "hand-pointing": "", + "hand-soap": "", + "hand-swipe-left": "", + "hand-swipe-right": "", + "hand-tap": "", + "hand-waving": "", + "hand-withdraw": "", + "handbag": "", + "handbag-simple": "", + "hands-clapping": "", + "hands-praying": "", + "handshake": "", + "hard-drive": "", + "hard-drives": "", + "hard-hat": "", + "hash": "", + "hash-straight": "", + "head-circuit": "", + "headlights": "", + "headphones": "", + "headset": "", + "heart": "", + "heart-break": "", + "heart-half": "", + "heart-straight": "", + "heart-straight-break": "", + "heartbeat": "", + "hexagon": "", + "high-definition": "", + "high-heel": "", + "highlighter": "", + "highlighter-circle": "", + "hockey": "", + "hoodie": "", + "horse": "", + "hospital": "", + "hourglass": "", + "hourglass-high": "", + "hourglass-low": "", + "hourglass-medium": "", + "hourglass-simple": "", + "hourglass-simple-high": "", + "hourglass-simple-low": "", + "hourglass-simple-medium": "", + "house": "", + "house-line": "", + "house-simple": "", + "hurricane": "", + "ice-cream": "", + "identification-badge": "", + "identification-card": "", + "image": "", + "image-broken": "", + "image-square": "", + "images": "", + "images-square": "", + "infinity": "", + "info": "", + "instagram-logo": "", + "intersect": "", + "intersect-square": "", + "intersect-three": "", + "intersection": "", + "invoice": "", + "island": "", + "jar": "", + "jar-label": "", + "jeep": "", + "joystick": "", + "kanban": "", + "key": "", + "key-return": "", + "keyboard": "", + "keyhole": "", + "knife": "", + "ladder": "", + "ladder-simple": "", + "lamp": "", + "lamp-pendant": "", + "laptop": "", + "lasso": "", + "lastfm-logo": "", + "layout": "", + "leaf": "", + "lectern": "", + "lego": "", + "lego-smiley": "", + "less-than": "", + "less-than-or-equal": "", + "letter-circle-h": "", + "letter-circle-p": "", + "letter-circle-v": "", + "lifebuoy": "", + "lightbulb": "", + "lightbulb-filament": "", + "lighthouse": "", + "lightning": "", + "lightning-a": "", + "lightning-slash": "", + "line-segment": "", + "line-segments": "", + "line-vertical": "", + "link": "", + "link-break": "", + "link-simple": "", + "link-simple-break": "", + "link-simple-horizontal": "", + "link-simple-horizontal-break": "", + "linkedin-logo": "", + "linktree-logo": "", + "linux-logo": "", + "list": "", + "list-bullets": "", + "list-checks": "", + "list-dashes": "", + "list-heart": "", + "list-magnifying-glass": "", + "list-numbers": "", + "list-plus": "", + "list-star": "", + "lock": "", + "lock-key": "", + "lock-key-open": "", + "lock-laminated": "", + "lock-laminated-open": "", + "lock-open": "", + "lock-simple": "", + "lock-simple-open": "", + "lockers": "", + "log": "", + "magic-wand": "", + "magnet": "", + "magnet-straight": "", + "magnifying-glass": "", + "magnifying-glass-minus": "", + "magnifying-glass-plus": "", + "mailbox": "", + "map-pin": "", + "map-pin-area": "", + "map-pin-line": "", + "map-pin-plus": "", + "map-pin-simple": "", + "map-pin-simple-area": "", + "map-pin-simple-line": "", + "map-trifold": "", + "markdown-logo": "", + "marker-circle": "", + "martini": "", + "mask-happy": "", + "mask-sad": "", + "mastodon-logo": "", + "math-operations": "", + "matrix-logo": "", + "medal": "", + "medal-military": "", + "medium-logo": "", + "megaphone": "", + "megaphone-simple": "", + "member-of": "", + "memory": "", + "messenger-logo": "", + "meta-logo": "", + "meteor": "", + "metronome": "", + "microphone": "", + "microphone-slash": "", + "microphone-stage": "", + "microscope": "", + "microsoft-excel-logo": "", + "microsoft-outlook-logo": "", + "microsoft-powerpoint-logo": "", + "microsoft-teams-logo": "", + "microsoft-word-logo": "", + "minus": "", + "minus-circle": "", + "minus-square": "", + "money": "", + "money-wavy": "", + "monitor": "", + "monitor-arrow-up": "", + "monitor-play": "", + "moon": "", + "moon-stars": "", + "moped": "", + "moped-front": "", + "mosque": "", + "motorcycle": "", + "mountains": "", + "mouse": "", + "mouse-left-click": "", + "mouse-middle-click": "", + "mouse-right-click": "", + "mouse-scroll": "", + "mouse-simple": "", + "music-note": "", + "music-note-simple": "", + "music-notes": "", + "music-notes-minus": "", + "music-notes-plus": "", + "music-notes-simple": "", + "navigation-arrow": "", + "needle": "", + "network": "", + "network-slash": "", + "network-x": "", + "newspaper": "", + "newspaper-clipping": "", + "not-equals": "", + "not-member-of": "", + "not-subset-of": "", + "not-superset-of": "", + "notches": "", + "note": "", + "note-blank": "", + "note-pencil": "", + "notebook": "", + "notepad": "", + "notification": "", + "notion-logo": "", + "nuclear-plant": "", + "number-circle-eight": "", + "number-circle-five": "", + "number-circle-four": "", + "number-circle-nine": "", + "number-circle-one": "", + "number-circle-seven": "", + "number-circle-six": "", + "number-circle-three": "", + "number-circle-two": "", + "number-circle-zero": "", + "number-eight": "", + "number-five": "", + "number-four": "", + "number-nine": "", + "number-one": "", + "number-seven": "", + "number-six": "", + "number-square-eight": "", + "number-square-five": "", + "number-square-four": "", + "number-square-nine": "", + "number-square-one": "", + "number-square-seven": "", + "number-square-six": "", + "number-square-three": "", + "number-square-two": "", + "number-square-zero": "", + "number-three": "", + "number-two": "", + "number-zero": "", + "numpad": "", + "nut": "", + "ny-times-logo": "", + "octagon": "", + "office-chair": "", + "onigiri": "", + "open-ai-logo": "", + "option": "", + "orange": "", + "orange-slice": "", + "oven": "", + "package": "", + "paint-brush": "", + "paint-brush-broad": "", + "paint-brush-household": "", + "paint-bucket": "", + "paint-roller": "", + "palette": "", + "panorama": "", + "pants": "", + "paper-plane": "", + "paper-plane-right": "", + "paper-plane-tilt": "", + "paperclip": "", + "paperclip-horizontal": "", + "parachute": "", + "paragraph": "", + "parallelogram": "", + "park": "", + "password": "", + "path": "", + "patreon-logo": "", + "pause": "", + "pause-circle": "", + "paw-print": "", + "paypal-logo": "", + "peace": "", + "pen": "", + "pen-nib": "", + "pen-nib-straight": "", + "pencil": "", + "pencil-circle": "", + "pencil-line": "", + "pencil-ruler": "", + "pencil-simple": "", + "pencil-simple-line": "", + "pencil-simple-slash": "", + "pencil-slash": "", + "pentagon": "", + "pentagram": "", + "pepper": "", + "percent": "", + "person": "", + "person-arms-spread": "", + "person-simple": "", + "person-simple-bike": "", + "person-simple-circle": "", + "person-simple-hike": "", + "person-simple-run": "", + "person-simple-ski": "", + "person-simple-snowboard": "", + "person-simple-swim": "", + "person-simple-tai-chi": "", + "person-simple-throw": "", + "person-simple-walk": "", + "perspective": "", + "phone": "", + "phone-call": "", + "phone-disconnect": "", + "phone-incoming": "", + "phone-list": "", + "phone-outgoing": "", + "phone-pause": "", + "phone-plus": "", + "phone-slash": "", + "phone-transfer": "", + "phone-x": "", + "phosphor-logo": "", + "pi": "", + "piano-keys": "", + "picnic-table": "", + "picture-in-picture": "", + "piggy-bank": "", + "pill": "", + "ping-pong": "", + "pint-glass": "", + "pinterest-logo": "", + "pinwheel": "", + "pipe": "", + "pipe-wrench": "", + "pix-logo": "", + "pizza": "", + "placeholder": "", + "planet": "", + "plant": "", + "play": "", + "play-circle": "", + "play-pause": "", + "playlist": "", + "plug": "", + "plug-charging": "", + "plugs": "", + "plugs-connected": "", + "plus": "", + "plus-circle": "", + "plus-minus": "", + "plus-square": "", + "poker-chip": "", + "police-car": "", + "polygon": "", + "popcorn": "", + "popsicle": "", + "potted-plant": "", + "power": "", + "prescription": "", + "presentation": "", + "presentation-chart": "", + "printer": "", + "prohibit": "", + "prohibit-inset": "", + "projector-screen": "", + "projector-screen-chart": "", + "pulse": "", + "push-pin": "", + "push-pin-simple": "", + "push-pin-simple-slash": "", + "push-pin-slash": "", + "puzzle-piece": "", + "qr-code": "", + "question": "", + "question-mark": "", + "queue": "", + "quotes": "", + "rabbit": "", + "racquet": "", + "radical": "", + "radio": "", + "radio-button": "", + "radioactive": "", + "rainbow": "", + "rainbow-cloud": "", + "ranking": "", + "read-cv-logo": "", + "receipt": "", + "receipt-x": "", + "record": "", + "rectangle": "", + "rectangle-dashed": "", + "recycle": "", + "reddit-logo": "", + "repeat": "", + "repeat-once": "", + "replit-logo": "", + "resize": "", + "rewind": "", + "rewind-circle": "", + "road-horizon": "", + "robot": "", + "rocket": "", + "rocket-launch": "", + "rows": "", + "rows-plus-bottom": "", + "rows-plus-top": "", + "rss": "", + "rss-simple": "", + "rug": "", + "ruler": "", + "sailboat": "", + "scales": "", + "scan": "", + "scan-smiley": "", + "scissors": "", + "scooter": "", + "screencast": "", + "screwdriver": "", + "scribble": "", + "scribble-loop": "", + "scroll": "", + "seal": "", + "seal-check": "", + "seal-percent": "", + "seal-question": "", + "seal-warning": "", + "seat": "", + "seatbelt": "", + "security-camera": "", + "selection": "", + "selection-all": "", + "selection-background": "", + "selection-foreground": "", + "selection-inverse": "", + "selection-plus": "", + "selection-slash": "", + "shapes": "", + "share": "", + "share-fat": "", + "share-network": "", + "shield": "", + "shield-check": "", + "shield-checkered": "", + "shield-chevron": "", + "shield-plus": "", + "shield-slash": "", + "shield-star": "", + "shield-warning": "", + "shipping-container": "", + "shirt-folded": "", + "shooting-star": "", + "shopping-bag": "", + "shopping-bag-open": "", + "shopping-cart": "", + "shopping-cart-simple": "", + "shovel": "", + "shower": "", + "shrimp": "", + "shuffle": "", + "shuffle-angular": "", + "shuffle-simple": "", + "sidebar": "", + "sidebar-simple": "", + "sigma": "", + "sign-in": "", + "sign-out": "", + "signature": "", + "signpost": "", + "sim-card": "", + "siren": "", + "sketch-logo": "", + "skip-back": "", + "skip-back-circle": "", + "skip-forward": "", + "skip-forward-circle": "", + "skull": "", + "skype-logo": "", + "slack-logo": "", + "sliders": "", + "sliders-horizontal": "", + "slideshow": "", + "smiley": "", + "smiley-angry": "", + "smiley-blank": "", + "smiley-meh": "", + "smiley-melting": "", + "smiley-nervous": "", + "smiley-sad": "", + "smiley-sticker": "", + "smiley-wink": "", + "smiley-x-eyes": "", + "snapchat-logo": "", + "sneaker": "", + "sneaker-move": "", + "snowflake": "", + "soccer-ball": "", + "sock": "", + "solar-panel": "", + "solar-roof": "", + "sort-ascending": "", + "sort-descending": "", + "soundcloud-logo": "", + "spade": "", + "sparkle": "", + "speaker-hifi": "", + "speaker-high": "", + "speaker-low": "", + "speaker-none": "", + "speaker-simple-high": "", + "speaker-simple-low": "", + "speaker-simple-none": "", + "speaker-simple-slash": "", + "speaker-simple-x": "", + "speaker-slash": "", + "speaker-x": "", + "speedometer": "", + "sphere": "", + "spinner": "", + "spinner-ball": "", + "spinner-gap": "", + "spiral": "", + "split-horizontal": "", + "split-vertical": "", + "spotify-logo": "", + "spray-bottle": "", + "square": "", + "square-half": "", + "square-half-bottom": "", + "square-logo": "", + "square-split-horizontal": "", + "square-split-vertical": "", + "squares-four": "", + "stack": "", + "stack-minus": "", + "stack-overflow-logo": "", + "stack-plus": "", + "stack-simple": "", + "stairs": "", + "stamp": "", + "standard-definition": "", + "star": "", + "star-and-crescent": "", + "star-four": "", + "star-half": "", + "star-of-david": "", + "steam-logo": "", + "steering-wheel": "", + "steps": "", + "stethoscope": "", + "sticker": "", + "stool": "", + "stop": "", + "stop-circle": "", + "storefront": "", + "strategy": "", + "stripe-logo": "", + "student": "", + "subset-of": "", + "subset-proper-of": "", + "subtitles": "", + "subtitles-slash": "", + "subtract": "", + "subtract-square": "", + "subway": "", + "suitcase": "", + "suitcase-rolling": "", + "suitcase-simple": "", + "sun": "", + "sun-dim": "", + "sun-horizon": "", + "sunglasses": "", + "superset-of": "", + "superset-proper-of": "", + "swap": "", + "swatches": "", + "swimming-pool": "", + "sword": "", + "synagogue": "", + "syringe": "", + "t-shirt": "", + "table": "", + "tabs": "", + "tag": "", + "tag-chevron": "", + "tag-simple": "", + "target": "", + "taxi": "", + "tea-bag": "", + "telegram-logo": "", + "television": "", + "television-simple": "", + "tennis-ball": "", + "tent": "", + "terminal": "", + "terminal-window": "", + "test-tube": "", + "text-a-underline": "", + "text-aa": "", + "text-align-center": "", + "text-align-justify": "", + "text-align-left": "", + "text-align-right": "", + "text-b": "", + "text-columns": "", + "text-h": "", + "text-h-five": "", + "text-h-four": "", + "text-h-one": "", + "text-h-six": "", + "text-h-three": "", + "text-h-two": "", + "text-indent": "", + "text-italic": "", + "text-outdent": "", + "text-strikethrough": "", + "text-subscript": "", + "text-superscript": "", + "text-t": "", + "text-t-slash": "", + "text-underline": "", + "textbox": "", + "thermometer": "", + "thermometer-cold": "", + "thermometer-hot": "", + "thermometer-simple": "", + "threads-logo": "", + "three-d": "", + "thumbs-down": "", + "thumbs-up": "", + "ticket": "", + "tidal-logo": "", + "tiktok-logo": "", + "tilde": "", + "timer": "", + "tip-jar": "", + "tipi": "", + "tire": "", + "toggle-left": "", + "toggle-right": "", + "toilet": "", + "toilet-paper": "", + "toolbox": "", + "tooth": "", + "tornado": "", + "tote": "", + "tote-simple": "", + "towel": "", + "tractor": "", + "trademark": "", + "trademark-registered": "", + "traffic-cone": "", + "traffic-sign": "", + "traffic-signal": "", + "train": "", + "train-regional": "", + "train-simple": "", + "tram": "", + "translate": "", + "trash": "", + "trash-simple": "", + "tray": "", + "tray-arrow-down": "", + "tray-arrow-up": "", + "treasure-chest": "", + "tree": "", + "tree-evergreen": "", + "tree-palm": "", + "tree-structure": "", + "tree-view": "", + "trend-down": "", + "trend-up": "", + "triangle": "", + "triangle-dashed": "", + "trolley": "", + "trolley-suitcase": "", + "trophy": "", + "truck": "", + "truck-trailer": "", + "tumblr-logo": "", + "twitch-logo": "", + "twitter-logo": "", + "umbrella": "", + "umbrella-simple": "", + "union": "", + "unite": "", + "unite-square": "", + "upload": "", + "upload-simple": "", + "usb": "", + "user": "", + "user-check": "", + "user-circle": "", + "user-circle-check": "", + "user-circle-dashed": "", + "user-circle-gear": "", + "user-circle-minus": "", + "user-circle-plus": "", + "user-focus": "", + "user-gear": "", + "user-list": "", + "user-minus": "", + "user-plus": "", + "user-rectangle": "", + "user-sound": "", + "user-square": "", + "user-switch": "", + "users": "", + "users-four": "", + "users-three": "", + "van": "", + "vault": "", + "vector-three": "", + "vector-two": "", + "vibrate": "", + "video": "", + "video-camera": "", + "video-camera-slash": "", + "video-conference": "", + "vignette": "", + "vinyl-record": "", + "virtual-reality": "", + "virus": "", + "visor": "", + "voicemail": "", + "volleyball": "", + "wall": "", + "wallet": "", + "warehouse": "", + "warning": "", + "warning-circle": "", + "warning-diamond": "", + "warning-octagon": "", + "washing-machine": "", + "watch": "", + "wave-sawtooth": "", + "wave-sine": "", + "wave-square": "", + "wave-triangle": "", + "waveform": "", + "waveform-slash": "", + "waves": "", + "webcam": "", + "webcam-slash": "", + "webhooks-logo": "", + "wechat-logo": "", + "whatsapp-logo": "", + "wheelchair": "", + "wheelchair-motion": "", + "wifi-high": "", + "wifi-low": "", + "wifi-medium": "", + "wifi-none": "", + "wifi-slash": "", + "wifi-x": "", + "wind": "", + "windmill": "", + "windows-logo": "", + "wine": "", + "wrench": "", + "x": "", + "x-circle": "", + "x-logo": "", + "x-square": "", + "yarn": "", + "yin-yang": "", + "youtube-logo": "" +} diff --git a/packages/pdf/src/forme/primitives.tsx b/packages/pdf/src/forme/primitives.tsx new file mode 100644 index 000000000..fb9d54251 --- /dev/null +++ b/packages/pdf/src/forme/primitives.tsx @@ -0,0 +1,104 @@ +import type { ReactNode } from "react"; +import type { Style, StyleProp } from "./style-types"; +import { createElement } from "react"; + +export type { Style, StyleProp } from "./style-types"; + +/** + * The primitives templates are written with. They keep react-pdf's names and props, so templates carry over, and + * record host elements that `to-forme.ts` turns into Forme nodes. `#react-pdf-renderer` resolves to this module. + */ + +type DataAttributes = { [key: `data-${string}`]: string | number | boolean | undefined }; + +type FlowProps = { + style?: StyleProp | undefined; + /** False keeps the element on one page. */ + wrap?: boolean | undefined; + /** Starts the element on a new page. */ + break?: boolean | undefined; + /** Repeats the element on every page. */ + fixed?: boolean | undefined; + /** react-pdf only; Forme has no equivalent, so it's accepted and ignored. */ + minPresenceAhead?: number | undefined; + debug?: boolean | undefined; + id?: string | undefined; + bookmark?: string | undefined; + children?: ReactNode; +} & DataAttributes; + +export type ViewProps = FlowProps; + +export type TextProps = FlowProps & { + orphans?: number | undefined; + widows?: number | undefined; +}; + +export type LinkProps = TextProps & { src: string }; + +export type ImageProps = { + src: string | { uri: string } | undefined; + style?: StyleProp | undefined; + fixed?: boolean | undefined; + debug?: boolean | undefined; + cache?: boolean | undefined; +} & DataAttributes; + +export type SvgProps = { + /** SVG markup of the drawing, without the outer ``. */ + content: string; + viewBox?: string | undefined; + width?: number | string | undefined; + height?: number | string | undefined; + style?: StyleProp | undefined; + opacity?: number | undefined; +} & DataAttributes; + +export type PageSize = "A4" | "LETTER" | { width: number; height?: number | undefined }; + +export type PageProps = { + size?: PageSize | undefined; + style?: StyleProp | undefined; + wrap?: boolean | undefined; + children?: ReactNode; +} & DataAttributes; + +export type DocumentProps = { + title?: string | undefined; + author?: string | undefined; + subject?: string | undefined; + creator?: string | undefined; + producer?: string | undefined; + language?: string | undefined; + /** "auto" hyphenates words by the document language; "manual" breaks only at soft hyphens. */ + hyphenation?: "auto" | "manual" | undefined; + creationDate?: Date | undefined; + pageMode?: string | undefined; + children?: ReactNode; +}; + +export const HOST = { + document: "rr-document", + page: "rr-page", + view: "rr-view", + text: "rr-text", + link: "rr-link", + image: "rr-image", + svg: "rr-svg", +} as const; + +const host = +

(type: string) => + (props: P) => + createElement(type, props); + +export const Document = host(HOST.document); +export const Page = host(HOST.page); +export const View = host(HOST.view); +export const Text = host(HOST.text); +export const Link = host(HOST.link); +export const Image = host(HOST.image); +export const Svg = host(HOST.svg); + +/** Kept for react-pdf-style `StyleSheet.create(...)`: an identity that keeps the literal types. */ +export const StyleSheet = { create: >(styles: T): T => styles }; diff --git a/packages/pdf/src/forme/reconciler.ts b/packages/pdf/src/forme/reconciler.ts new file mode 100644 index 000000000..5d620c12f --- /dev/null +++ b/packages/pdf/src/forme/reconciler.ts @@ -0,0 +1,137 @@ +import type { ReactNode } from "react"; +import Reconciler from "react-reconciler"; +import { ConcurrentRoot, DefaultEventPriority } from "react-reconciler/constants.js"; + +/** An element the templates rendered: one of the host primitives in `primitives.tsx`, with its resolved props. */ +export type HostElement = { type: string; props: Record; children: HostNode[] }; +export type HostText = { type: "#text"; text: string }; +export type HostNode = HostElement | HostText; + +type Container = { children: HostNode[] }; + +const append = (parent: { children: HostNode[] }, child: HostNode) => { + parent.children.push(child); +}; + +const insertBefore = (parent: { children: HostNode[] }, child: HostNode, before: HostNode) => { + const index = parent.children.indexOf(before); + parent.children.splice(index < 0 ? parent.children.length : index, 0, child); +}; + +const remove = (parent: { children: HostNode[] }, child: HostNode) => { + const index = parent.children.indexOf(child); + if (index >= 0) parent.children.splice(index, 1); +}; + +const withoutChildren = (props: Record) => { + const { children: _children, ...rest } = props; + return rest; +}; + +let currentPriority: number = DefaultEventPriority; + +/** + * A minimal React renderer: it runs the templates with real hooks and context and records the host elements they + * produce. Forme's own serializer calls components as plain functions, which hooks and context can't survive. + */ +const reconciler = Reconciler({ + supportsMutation: true, + supportsPersistence: false, + supportsHydration: false, + isPrimaryRenderer: false, + noTimeout: -1, + createInstance: (type: string, props: Record): HostElement => ({ + type, + props: withoutChildren(props), + children: [], + }), + createTextInstance: (text: string): HostText => ({ type: "#text", text }), + appendInitialChild: append, + appendChild: append, + appendChildToContainer: append, + insertBefore, + insertInContainerBefore: insertBefore, + removeChild: remove, + removeChildFromContainer: remove, + commitUpdate: ( + instance: HostElement, + _type: string, + _old: Record, + next: Record, + ) => { + instance.props = withoutChildren(next); + }, + commitTextUpdate: (instance: HostText, _old: string, next: string) => { + instance.text = next; + }, + finalizeInitialChildren: () => false, + shouldSetTextContent: () => false, + getPublicInstance: (instance: HostNode) => instance, + getRootHostContext: () => ({}), + getChildHostContext: (parent: object) => parent, + prepareForCommit: () => null, + resetAfterCommit: () => {}, + clearContainer: (container: Container) => { + container.children = []; + }, + resetTextContent: () => {}, + hideInstance: () => {}, + unhideInstance: () => {}, + hideTextInstance: () => {}, + unhideTextInstance: () => {}, + detachDeletedInstance: () => {}, + preparePortalMount: () => {}, + scheduleTimeout: setTimeout, + cancelTimeout: clearTimeout, + getCurrentUpdatePriority: () => currentPriority, + setCurrentUpdatePriority: (priority: number) => { + currentPriority = priority; + }, + resolveUpdatePriority: () => currentPriority, + getInstanceFromNode: () => null, + beforeActiveInstanceBlur: () => {}, + afterActiveInstanceBlur: () => {}, + prepareScopeUpdate: () => {}, + getInstanceFromScope: () => null, + maySuspendCommit: () => false, + preloadInstance: () => true, + startSuspendingCommit: () => {}, + suspendInstance: () => {}, + waitForCommitToBeReady: () => null, + requestPostPaintCallback: () => {}, + shouldAttemptEagerTransition: () => false, + trackSchedulerEvent: () => {}, + resolveEventType: () => null, + resolveEventTimeStamp: () => -1.1, + NotPendingTransition: null, + HostTransitionContext: { + $$typeof: Symbol.for("react.context"), + _currentValue: null, + _currentValue2: null, + }, + resetFormInstance: () => {}, + // biome-ignore lint/suspicious/noExplicitAny: the host config typings trail react-reconciler 0.34. +} as any); + +/** Renders `element` synchronously and returns the host tree it produced. Errors thrown while rendering rethrow. */ +export function renderHostTree(element: ReactNode): HostNode[] { + const container: Container = { children: [] }; + const errors: unknown[] = []; + const onError = (error: unknown) => { + errors.push(error); + }; + // biome-ignore lint/suspicious/noExplicitAny: createContainer's arity changes across 0.3x releases. + const create = reconciler.createContainer as (...args: any[]) => unknown; + const root = create(container, ConcurrentRoot, null, false, null, "", onError, onError, onError, () => {}, null); + // biome-ignore lint/suspicious/noExplicitAny: `updateContainerSync` and `flushSyncWork` exist from 0.31. + const sync = reconciler as any; + sync.updateContainerSync(element, root, null, null); + sync.flushSyncWork(); + // Unmount so the fibers can be collected; the recorded tree stays in `container`. + // Unmounting detaches the top-level nodes from the container array, so keep a copy of it. + const tree = [...container.children]; + sync.updateContainerSync(null, root, null, null); + sync.flushSyncWork(); + if (errors.length > 0) throw errors[0]; + return tree; +} diff --git a/packages/pdf/src/forme/render.ts b/packages/pdf/src/forme/render.ts new file mode 100644 index 000000000..1bf66820c --- /dev/null +++ b/packages/pdf/src/forme/render.ts @@ -0,0 +1,118 @@ +import type { ElementInfo, RenderWithLayoutResult } from "@formepdf/core"; +import type { FormeDocument } from "@formepdf/react"; +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { Template } from "@reactive-resume/schema/templates"; +import type { Locale } from "@reactive-resume/utils/locale"; +import type { ResumeRenderOptions } from "../context"; +import type { PageMap } from "../page-map"; +import type { SectionTitleResolver } from "../section-title"; +import { createElement } from "react"; +import { parseResumeData } from "@reactive-resume/schema/resume/data"; +import { ResumeDocument } from "../document"; +import { resolvePdfFonts, resumeContentContainsCJK, resumeContentScripts } from "../hooks/use-register-fonts"; +import { extractPageMap } from "../page-map"; +import { loadFonts } from "./fonts"; +import { loadIcons } from "./icons"; +import { imageSources, loadImages } from "./images"; +import { renderHostTree } from "./reconciler"; +import { FIXED_SOURCE, FREE_FORM_MEASURE_HEIGHT, toFormeDocument } from "./to-forme"; + +/** The Forme entry point for this runtime: `@formepdf/core` in Node, `@formepdf/core/worker` in browsers. */ +export type FormeEngine = { + renderSerializedDocWithLayout: (document: Record) => Promise; +}; + +export type RenderResumeInput = { + data: ResumeData; + template?: Template | undefined; + renderOptions?: ResumeRenderOptions | undefined; + resolveSectionTitle?: SectionTitleResolver | undefined; +}; + +export type RenderedResume = { + pdf: Uint8Array; + /** Header, section and item boxes, for click-to-edit, Check and Fit (see `page-map.ts`). */ + pageMap: PageMap; + /** Forme's layout of every page, for tests and diagnostics. */ + layout: RenderWithLayoutResult["layout"]; + /** What the engine couldn't draw as asked; for diagnostics, never shown as errors. */ + warnings: string[]; +}; + +// A4 height: a free-form page is never shorter than a sheet of paper, as before. +const FREE_FORM_MIN_HEIGHT = 841.89; + +type PageKind = Extract; + +const isFixed = (element: ElementInfo): boolean => + element.sourceLocation?.file === FIXED_SOURCE || element.children.some(isFixed); + +/** A free-form page's content height: the lowest box on it plus the page's bottom margin. */ +function measuredHeight(result: RenderWithLayoutResult, pageIndex: number, marginBottom: number) { + const page = result.layout.pages[pageIndex]; + if (!page) return FREE_FORM_MIN_HEIGHT; + // Repeated backgrounds span the page they're measured on, so they don't count. + const content = page.elements.filter((element) => !isFixed(element)); + const bottom = content.reduce((lowest, element) => Math.max(lowest, element.y + element.height), 0); + return Math.max(FREE_FORM_MIN_HEIGHT, Math.ceil(bottom + marginBottom)); +} + +/** + * Renders a resume or letter to PDF bytes with its page map. Every PDF in the app goes through here: the live + * preview, downloads, thumbnails, the public page and the server export. + */ +export async function renderResume(engine: FormeEngine, input: RenderResumeInput): Promise { + const data = parseResumeData(input.data); + const template = input.template ?? data.metadata.template; + + const { fonts: fontRequests } = resolvePdfFonts( + data.metadata.typography, + data.metadata.page.locale as Locale, + resumeContentContainsCJK(data), + resumeContentScripts(data), + ); + const [{ fonts, warnings: fontWarnings }] = await Promise.all([loadFonts(fontRequests), loadIcons()]); + + const tree = renderHostTree( + createElement(ResumeDocument, { + data, + template, + ...(input.renderOptions ? { renderOptions: input.renderOptions } : {}), + resolveSectionTitle: input.resolveSectionTitle, + }), + ); + const { images, warnings: imageWarnings } = await loadImages(imageSources(tree)); + const { document, warnings } = toFormeDocument(tree, images); + document.fonts = fonts; + + let result = await engine.renderSerializedDocWithLayout({ ...document }); + + // Free-form pages grow with their content: measure it, then render once more at that height. + if (data.metadata.page.format === "free-form") { + let changed = false; + document.children.forEach((page, index) => { + const kind = page.kind as PageKind; + if (kind.type !== "Page" || typeof kind.config.size !== "object") return; + const { width, height } = kind.config.size.Custom; + if (height !== FREE_FORM_MEASURE_HEIGHT) return; + kind.config.size = { Custom: { width, height: measuredHeight(result, index, kind.config.margin.bottom) } }; + changed = true; + }); + if (changed) result = await engine.renderSerializedDocWithLayout({ ...document }); + } + + // Forme marks a box it failed to place with an infinite coordinate; the page is then likely wrong. + const misplaced = result.layout.pages.some((page) => + page.elements.some(function bad(element): boolean { + return !Number.isFinite(element.y) || !Number.isFinite(element.height) || element.children.some(bad); + }), + ); + if (misplaced) warnings.push("render defect: the engine couldn't place a box; a page may be laid out wrong"); + + return { + pdf: result.pdf, + pageMap: extractPageMap(result.layout), + layout: result.layout, + warnings: [...fontWarnings, ...imageWarnings, ...warnings, ...result.warnings], + }; +} diff --git a/packages/pdf/src/forme/style-types.ts b/packages/pdf/src/forme/style-types.ts new file mode 100644 index 000000000..f4e5fc1a5 --- /dev/null +++ b/packages/pdf/src/forme/style-types.ts @@ -0,0 +1,411 @@ +/** + * The style contract templates are written against. Vendored from @react-pdf/stylesheet 6.3.2 (MIT, © Diego + * Muracciole) when the engine moved to Forme, so templates keep their types; `forme/style.ts` maps it to Forme. + */ +export type Percentage = `${string}%`; +export type BorderStyleValue = "dashed" | "dotted" | "solid"; +export type BorderShorthandStyle = { + border?: number | string; + borderTop?: number | string; + borderRight?: number | string; + borderBottom?: number | string; + borderLeft?: number | string; + borderColor?: string; + borderRadius?: number | string; + borderStyle?: BorderStyleValue; + borderWidth?: number | string; +}; +export type BorderExpandedStyle = { + borderTopColor?: string; + borderTopStyle?: BorderStyleValue; + borderTopWidth?: number | string; + borderRightColor?: string; + borderRightStyle?: BorderStyleValue; + borderRightWidth?: number | string; + borderBottomColor?: string; + borderBottomStyle?: BorderStyleValue; + borderBottomWidth?: number | string; + borderLeftColor?: string; + borderLeftStyle?: BorderStyleValue; + borderLeftWidth?: number | string; + borderTopLeftRadius?: number | string; + borderTopRightRadius?: number | string; + borderBottomRightRadius?: number | string; + borderBottomLeftRadius?: number | string; +}; +export type BorderSafeStyle = BorderExpandedStyle & { + borderTopWidth?: number; + borderRightWidth?: number; + borderBottomWidth?: number; + borderLeftWidth?: number; + borderTopLeftRadius?: number | Percentage; + borderTopRightRadius?: number | Percentage; + borderBottomRightRadius?: number | Percentage; + borderBottomLeftRadius?: number | Percentage; +}; +export type BorderStyle = BorderShorthandStyle & BorderExpandedStyle; +export type FlexboxShorthandStyle = { + flex?: number | string; +}; +export type AlignContent = + | "flex-start" + | "flex-end" + | "center" + | "stretch" + | "space-between" + | "space-around" + | "space-evenly"; +export type AlignItems = "flex-start" | "flex-end" | "center" | "stretch" | "baseline"; +export type AlignSelf = "auto" | "flex-start" | "flex-end" | "center" | "baseline" | "stretch"; +export type FlexDirection = "row" | "row-reverse" | "column" | "column-reverse"; +export type FlexWrap = "nowrap" | "wrap" | "wrap-reverse"; +export type JustifyContent = "flex-start" | "flex-end" | "center" | "space-around" | "space-between" | "space-evenly"; +export type JustifySelf = string; +export type FlexboxExpandedStyle = { + alignContent?: AlignContent; + alignItems?: AlignItems; + alignSelf?: AlignSelf; + flexDirection?: FlexDirection; + flexWrap?: FlexWrap; + flexFlow?: number | string; + flexGrow?: number | string; + flexShrink?: number | string; + flexBasis?: number | string; + justifySelf?: JustifySelf; + justifyContent?: JustifyContent; +}; +export type FlexboxSafeStyle = FlexboxExpandedStyle & { + flexGrow?: number; + flexShrink?: number; +}; +export type FlexboxStyle = FlexboxShorthandStyle & FlexboxExpandedStyle; +export type GapShorthandStyle = { + gap?: number | string; +}; +export type GapExpandedStyle = { + rowGap?: number | string; + columnGap?: number | string; +}; +export type GapSafeStyle = { + rowGap?: number | string; + columnGap?: number | string; +}; +export type GapStyle = GapShorthandStyle & GapExpandedStyle; +export type PositionShorthandStyle = { + objectPosition?: number | string; +}; +export type PositionExpandedStyle = { + objectPositionX?: number | string; + objectPositionY?: number | string; + objectFit?: string; +}; +export type PositionSafeStyle = PositionExpandedStyle & { + objectPositionX?: number; + objectPositionY?: number; +}; +export type PositioningStyle = PositionShorthandStyle & PositionExpandedStyle; +export type ScaleTransform = { + operation: "scale"; + value: [number, number]; +}; +export type TranslateTransform = { + operation: "translate"; + value: [number, number]; +}; +export type RotateTransform = { + operation: "rotate"; + value: [number, number, number]; +}; +export type SkewTransform = { + operation: "skew"; + value: [number, number]; +}; +export type MatrixTransform = { + operation: "matrix"; + value: [number, number, number, number, number, number]; +}; +export type Transform = ScaleTransform | TranslateTransform | RotateTransform | SkewTransform | MatrixTransform; +export type TransformShorthandStyle = { + transformOrigin?: number | string; +}; +export type TransformExpandedStyle = { + transformOriginX?: number | string; + transformOriginY?: number | string; + transform?: string | Transform[]; + gradientTransform?: string | Transform[]; +}; +export type TransformSafeStyle = Omit & { + transformOriginX?: number | Percentage; + transformOriginY?: number | Percentage; + transform?: Transform[]; + gradientTransform?: Transform[]; +}; +export type TransformStyle = TransformShorthandStyle & TransformExpandedStyle; +export type Display = "flex" | "none"; +export type Position = "absolute" | "relative" | "static"; +export type Float = "left" | "right" | "none"; +export type Clear = Float | "both"; +export type ShapeScalar = number | Percentage; +export type ShapeRadius = ShapeScalar | "closest-side" | "farthest-side"; +export type ShapeOutside = + | { + type: "circle"; + cx: ShapeScalar; + cy: ShapeScalar; + r: ShapeRadius; + } + | { + type: "ellipse"; + cx: ShapeScalar; + cy: ShapeScalar; + rx: ShapeRadius; + ry: ShapeRadius; + } + | { + type: "polygon"; + points: { + x: ShapeScalar; + y: ShapeScalar; + }[]; + } + | { + type: "inset"; + top: ShapeScalar; + right: ShapeScalar; + bottom: ShapeScalar; + left: ShapeScalar; + }; +export type LayoutStyle = { + aspectRatio?: number | string; + bottom?: number | string; + clear?: Clear; + display?: Display; + float?: Float; + left?: number | string; + position?: Position; + right?: number | string; + shapeOutside?: string | ShapeOutside; + top?: number | string; + overflow?: "hidden"; + zIndex?: number | string; +}; +export type LayoutExpandedStyle = LayoutStyle; +export type LayoutSafeStyle = LayoutExpandedStyle & { + aspectRatio?: number; + bottom?: number; + clear?: Clear; + float?: Float; + left?: number; + right?: number; + shapeOutside?: ShapeOutside; + top?: number; + zIndex?: number; +}; +export type DimensionStyle = { + height?: number | string; + maxHeight?: number | string; + maxWidth?: number | string; + minHeight?: number | string; + minWidth?: number | string; + width?: number | string; +}; +export type DimensionExpandedStyle = DimensionStyle; +export type DimensionSafeStyle = DimensionExpandedStyle & { + height?: number | Percentage; + maxHeight?: number | Percentage; + maxWidth?: number | Percentage; + minHeight?: number | Percentage; + minWidth?: number | Percentage; + width?: number | Percentage; +}; +export type ColorStyle = { + backgroundColor?: string; + color?: string; + opacity?: number | string; +}; +export type ColorExpandedStyle = ColorStyle; +export type ColorSafeStyle = { + backgroundColor?: string; + color?: string; + opacity?: number; +}; +export type FontStyle = "normal" | "italic" | "oblique"; +export type FontWeight = + | string + | number + | "thin" + | "hairline" + | "ultralight" + | "extralight" + | "light" + | "normal" + | "medium" + | "semibold" + | "demibold" + | "bold" + | "ultrabold" + | "extrabold" + | "heavy" + | "black"; +export type TextAlign = "left" | "right" | "center" | "justify"; +export type TextDecoration = + | "line-through" + | "underline" + | "none" + | "line-through underline" + | "underline line-through"; +export type TextDecorationStyle = "dashed" | "dotted" | "solid" | string; +export type TextTransform = "capitalize" | "lowercase" | "uppercase" | "upperfirst" | "none"; +export type VerticalAlign = "sub" | "super"; +export type FontFeatureSetting = + | "liga" + | "dlig" + | "onum" + | "lnum" + | "tnum" + | "zero" + | "frac" + | "sups" + | "subs" + | "smcp" + | "c2sc" + | "case" + | "hlig" + | "calt" + | "swsh" + | "hist" + | `ss${"01" | "02" | "03" | "04" | "05" | "06" | "07" | "08" | "09" | "10" | "11" | "12" | "13" | "14" | "15" | "16" | "17" | "18" | "19" | "20"}` + | "kern" + | "locl" + | "rlig" + | "medi" + | "init" + | "isol" + | "fina" + | "mark" + | "mkmk" + | (string & {}); +export type FontFeatureSettings = FontFeatureSetting[] | Partial>; +export type SafeFontFeatureSettings = Record; +export type TextStyle = { + direction?: "ltr" | "rtl"; + fontSize?: number | string; + fontFamily?: string | string[]; + fontStyle?: FontStyle; + fontWeight?: FontWeight; + fontFeatureSettings?: FontFeatureSettings; + letterSpacing?: number | string; + lineHeight?: number | string; + maxLines?: number | string; + textAlign?: TextAlign; + textDecoration?: TextDecoration; + textDecorationColor?: string; + textDecorationStyle?: TextDecorationStyle; + textIndent?: number | string; + textOverflow?: "ellipsis"; + textTransform?: TextTransform; + verticalAlign?: VerticalAlign; +}; +export type TextExpandedStyle = TextStyle; +export type TextSafeStyle = TextExpandedStyle & { + fontSize?: number; + fontWeight?: number; + fontFeatureSettings?: SafeFontFeatureSettings; + letterSpacing?: number; + lineHeight?: number; +}; +export type MarginShorthandStyle = { + margin?: number | string; + marginHorizontal?: number | string; + marginVertical?: number | string; +}; +export type MarginExpandedStyle = { + marginTop?: number | string; + marginRight?: number | string; + marginBottom?: number | string; + marginLeft?: number | string; +}; +export type MarginSafeStyle = MarginExpandedStyle & { + marginTop?: number | Percentage; + marginRight?: number | Percentage; + marginBottom?: number | Percentage; + marginLeft?: number | Percentage; +}; +export type MarginStyle = MarginShorthandStyle & MarginExpandedStyle; +export type PaddingShorthandStyle = { + padding?: number | string; + paddingHorizontal?: number | string; + paddingVertical?: number | string; +}; +export type PaddingExpandedStyle = { + paddingTop?: number | string; + paddingRight?: number | string; + paddingBottom?: number | string; + paddingLeft?: number | string; +}; +export type PaddingSafeStyle = PaddingExpandedStyle & { + paddingTop?: number | Percentage; + paddingRight?: number | Percentage; + paddingBottom?: number | Percentage; + paddingLeft?: number | Percentage; +}; +export type PaddingStyle = PaddingShorthandStyle & PaddingExpandedStyle; +interface SvgStyle { + fill?: string; + stroke?: string; + strokeDasharray?: string; + strokeWidth?: string | number; + fillOpacity?: string | number; + fillRule?: "nonzero" | "evenodd"; + strokeOpacity?: string | number; + textAnchor?: "start" | "middle" | "end"; + strokeLinecap?: "butt" | "round" | "square"; + strokeLinejoin?: "butt" | "round" | "square" | "miter" | "bevel"; + visibility?: "visible" | "hidden" | "collapse"; + clipPath?: string; + dominantBaseline?: + | "auto" + | "middle" + | "central" + | "hanging" + | "mathematical" + | "text-after-edge" + | "text-before-edge"; +} +export type SvgExpandedStyle = SvgStyle; +export type SvgSafeStyle = SvgStyle & { + strokeWidth?: number; + fillOpacity?: number; + strokeOpacity?: number; +}; +export type BaseStyle = BorderStyle & + ColorStyle & + DimensionStyle & + FlexboxStyle & + GapStyle & + LayoutStyle & + MarginStyle & + PaddingStyle & + PositioningStyle & + TextStyle & + TransformStyle & + SvgStyle; +export type MediaQueryStyle = { + [key in `@media${string}`]: BaseStyle; +}; +export type Style = BaseStyle & MediaQueryStyle; +export type RecursiveArray = Array | RecursiveArray>; +export type StyleProp = Style | Style[] | RecursiveArray