mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
feat(pdf): render resumes with the forme engine
Templates keep their react-pdf-style primitives. A small React renderer records what they draw, and the result is converted to a Forme document: styles, fonts (with ligatures disabled for text extraction), Phosphor icons as SVG paths, pictures fitted by object-fit, and the page map from source locations. Workarounds for Forme 0.25 layout defects live in the converter: opaque translucent colours, border insets, row splitting, column gaps across pages, absolute positioning and repeated backgrounds. Consumers, the Semantic CSS adapter and the old react-pdf dependencies follow in later commits.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Blob> => {
|
||||
const data = parseResumeData(input);
|
||||
const document = createElement(ResumeDocument, {
|
||||
data,
|
||||
template: template ?? data.metadata.template,
|
||||
...(renderOptions ? { renderOptions } : {}),
|
||||
resolveSectionTitle,
|
||||
onPageMap,
|
||||
}) as Parameters<typeof pdf>[0];
|
||||
|
||||
return await pdf(document).toBlob();
|
||||
export const createResumePdfBlob = async ({ onPageMap, ...input }: CreateResumePdfBlobOptions): Promise<Blob> => {
|
||||
// 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<ArrayBuffer>], { type: "application/pdf" });
|
||||
};
|
||||
|
||||
@@ -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<typeof createHyphenationCallback>;
|
||||
};
|
||||
|
||||
const RenderContext = createContext<RenderContextValue | null>(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<RenderContextValue>(
|
||||
() => ({ ...data, resolveSectionTitle, renderOptions, rtl, hyphenationCallback }),
|
||||
[data, resolveSectionTitle, renderOptions, rtl, hyphenationCallback],
|
||||
() => ({ ...data, resolveSectionTitle, renderOptions, rtl }),
|
||||
[data, resolveSectionTitle, renderOptions, rtl],
|
||||
);
|
||||
|
||||
return <RenderContext.Provider value={contextValue}>{children}</RenderContext.Provider>;
|
||||
|
||||
@@ -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 (
|
||||
<SemanticRenderProvider
|
||||
presentation={runtime.presentation}
|
||||
@@ -107,7 +88,7 @@ export const ResumeDocument = ({
|
||||
creator={resumeData.basics.name}
|
||||
subject={resumeData.basics.headline}
|
||||
language={resumeData.metadata.page.locale}
|
||||
{...pageMapProps}
|
||||
hyphenation={resumeData.metadata.typography.hyphenation === true ? "auto" : "manual"}
|
||||
>
|
||||
{resumeData.metadata.layout.pages.map((page, index) => (
|
||||
<TemplatePageComponent
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { unzlibSync } from "fflate";
|
||||
|
||||
/**
|
||||
* Font bytes for Forme: WOFF files become plain sfnt, and ligatures are switched off.
|
||||
*
|
||||
* Forme 0.25 maps a ligature glyph back to only its first character in the PDF's ToUnicode table, so "Profiles"
|
||||
* set with an fi ligature extracts as "Profles" and applicant tracking systems read the wrong word
|
||||
* (danmolitor/forme#156). Renaming the GSUB `liga`, `clig`, `dlig` and `hlig` feature tags stops the shaper from
|
||||
* applying them. `rlig` stays: Arabic needs its required ligatures to be readable at all.
|
||||
* ponytail: byte patch until Forme fixes #156; drop it then.
|
||||
*/
|
||||
|
||||
const WOFF = 0x774f4646; // "wOFF"
|
||||
const LIGATURE_FEATURES = new Set(["liga", "clig", "dlig", "hlig"]);
|
||||
// Any tag the shaper doesn't know works; this one says why it's there.
|
||||
const DISABLED_TAG = [0x78, 0x6c, 0x69, 0x67]; // "xlig"
|
||||
|
||||
const tagAt = (view: DataView, offset: number) =>
|
||||
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;
|
||||
}
|
||||
@@ -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<string, Promise<Uint8Array>>();
|
||||
|
||||
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<FormeFont | null> => {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, string> | undefined;
|
||||
let loading: Promise<void> | 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<string, string>;
|
||||
}));
|
||||
|
||||
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
|
||||
viewBox="0 0 256 256"
|
||||
width={size}
|
||||
height={size}
|
||||
content={markup.replaceAll("<path ", `<path ${paint} `)}
|
||||
style={flat}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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}"`;
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<string, LoadedImage>();
|
||||
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 };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 `<svg>`. */
|
||||
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 =
|
||||
<P extends object>(type: string) =>
|
||||
(props: P) =>
|
||||
createElement(type, props);
|
||||
|
||||
export const Document = host<DocumentProps>(HOST.document);
|
||||
export const Page = host<PageProps>(HOST.page);
|
||||
export const View = host<ViewProps>(HOST.view);
|
||||
export const Text = host<TextProps>(HOST.text);
|
||||
export const Link = host<LinkProps>(HOST.link);
|
||||
export const Image = host<ImageProps>(HOST.image);
|
||||
export const Svg = host<SvgProps>(HOST.svg);
|
||||
|
||||
/** Kept for react-pdf-style `StyleSheet.create(...)`: an identity that keeps the literal types. */
|
||||
export const StyleSheet = { create: <T extends Record<string, Style>>(styles: T): T => styles };
|
||||
@@ -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<string, unknown>; 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<string, unknown>) => {
|
||||
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<string, unknown>): 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<string, unknown>,
|
||||
next: Record<string, unknown>,
|
||||
) => {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown>) => Promise<RenderWithLayoutResult>;
|
||||
};
|
||||
|
||||
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<FormeDocument["children"][number]["kind"], { type: "Page" }>;
|
||||
|
||||
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<RenderedResume> {
|
||||
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],
|
||||
};
|
||||
}
|
||||
@@ -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<TransformExpandedStyle, "transform"> & {
|
||||
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<Record<FontFeatureSetting, number | boolean>>;
|
||||
export type SafeFontFeatureSettings = Record<string, boolean>;
|
||||
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<T> = Array<T | ReadonlyArray<T> | RecursiveArray<T>>;
|
||||
export type StyleProp = Style | Style[] | RecursiveArray<Style | Style[] | undefined>;
|
||||
export type StyleKey = keyof BaseStyle;
|
||||
export type ExpandedStyle = BorderExpandedStyle &
|
||||
ColorExpandedStyle &
|
||||
DimensionExpandedStyle &
|
||||
FlexboxExpandedStyle &
|
||||
GapExpandedStyle &
|
||||
LayoutExpandedStyle &
|
||||
MarginExpandedStyle &
|
||||
PaddingExpandedStyle &
|
||||
PositionExpandedStyle &
|
||||
TextExpandedStyle &
|
||||
TransformExpandedStyle &
|
||||
SvgExpandedStyle;
|
||||
@@ -0,0 +1,484 @@
|
||||
import type { Style as FormeStyle } from "@formepdf/react";
|
||||
import type { Style, StyleProp } from "./style-types";
|
||||
|
||||
/**
|
||||
* react-pdf-style objects (what templates and Semantic CSS produce) → Forme style objects.
|
||||
*
|
||||
* Forme's style model is close to react-pdf's; the differences handled here: numeric strings with units, absolute
|
||||
* line heights (Forme only takes a multiplier), font-weight keywords, font fallback arrays, named colours, and
|
||||
* properties Forme can't draw, which are dropped and reported.
|
||||
*/
|
||||
|
||||
export type ConvertedStyle = {
|
||||
style: FormeStyle;
|
||||
/** Properties dropped because Forme has no equivalent. */
|
||||
dropped: string[];
|
||||
/** `display: none` — the element isn't drawn. */
|
||||
hidden: boolean;
|
||||
};
|
||||
|
||||
export const flattenStyle = (style: StyleProp | undefined | null | false): Style => {
|
||||
if (!style) return {};
|
||||
if (!Array.isArray(style)) return style as Style;
|
||||
return (style as unknown[]).reduce<Style>((merged, entry) => {
|
||||
const flat = flattenStyle(entry as StyleProp);
|
||||
return Object.assign(merged, flat);
|
||||
}, {});
|
||||
};
|
||||
|
||||
const DPI = 72;
|
||||
const UNIT = /^(-?\d*\.?\d+)(pt|px|in|mm|cm|em|rem|vw|vh)?$/;
|
||||
|
||||
type Context = { fontSize: number; pageWidth?: number | undefined; pageHeight?: number | undefined };
|
||||
|
||||
/** A length in points, or the percentage string as is. `undefined` when it isn't a length. */
|
||||
export function toPoints(value: unknown, context: Context): number | string | undefined {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.endsWith("%")) return trimmed;
|
||||
const match = UNIT.exec(trimmed);
|
||||
if (!match) return undefined;
|
||||
const number = Number(match[1]);
|
||||
switch (match[2]) {
|
||||
case "in":
|
||||
return number * DPI;
|
||||
case "mm":
|
||||
return number * (DPI / 25.4);
|
||||
case "cm":
|
||||
return number * (DPI / 2.54);
|
||||
case "em":
|
||||
case "rem":
|
||||
return number * context.fontSize;
|
||||
case "vw":
|
||||
return context.pageWidth === undefined ? undefined : number * (context.pageWidth / 100);
|
||||
case "vh":
|
||||
return context.pageHeight === undefined ? undefined : number * (context.pageHeight / 100);
|
||||
default:
|
||||
return number;
|
||||
}
|
||||
}
|
||||
|
||||
const FONT_WEIGHTS: Record<string, number> = {
|
||||
thin: 100,
|
||||
hairline: 100,
|
||||
ultralight: 200,
|
||||
extralight: 200,
|
||||
light: 300,
|
||||
normal: 400,
|
||||
regular: 400,
|
||||
medium: 500,
|
||||
semibold: 600,
|
||||
demibold: 600,
|
||||
bold: 700,
|
||||
ultrabold: 800,
|
||||
extrabold: 800,
|
||||
heavy: 900,
|
||||
black: 900,
|
||||
};
|
||||
|
||||
export const toFontWeight = (value: unknown): number | undefined => {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value !== "string") return undefined;
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) return numeric;
|
||||
return FONT_WEIGHTS[value.toLowerCase()];
|
||||
};
|
||||
|
||||
// CSS named colours Forme's parser doesn't know. Kept short: the ones templates and stylesheets use in practice.
|
||||
const NAMED_COLORS: Record<string, string> = {
|
||||
transparent: "rgba(0, 0, 0, 0)",
|
||||
black: "#000000",
|
||||
white: "#ffffff",
|
||||
gray: "#808080",
|
||||
grey: "#808080",
|
||||
silver: "#c0c0c0",
|
||||
red: "#ff0000",
|
||||
maroon: "#800000",
|
||||
orange: "#ffa500",
|
||||
yellow: "#ffff00",
|
||||
olive: "#808000",
|
||||
lime: "#00ff00",
|
||||
green: "#008000",
|
||||
teal: "#008080",
|
||||
aqua: "#00ffff",
|
||||
cyan: "#00ffff",
|
||||
blue: "#0000ff",
|
||||
navy: "#000080",
|
||||
fuchsia: "#ff00ff",
|
||||
magenta: "#ff00ff",
|
||||
purple: "#800080",
|
||||
pink: "#ffc0cb",
|
||||
brown: "#a52a2a",
|
||||
gold: "#ffd700",
|
||||
indigo: "#4b0082",
|
||||
violet: "#ee82ee",
|
||||
crimson: "#dc143c",
|
||||
coral: "#ff7f50",
|
||||
salmon: "#fa8072",
|
||||
tomato: "#ff6347",
|
||||
khaki: "#f0e68c",
|
||||
beige: "#f5f5dc",
|
||||
ivory: "#fffff0",
|
||||
lavender: "#e6e6fa",
|
||||
turquoise: "#40e0d0",
|
||||
tan: "#d2b48c",
|
||||
chocolate: "#d2691e",
|
||||
darkgray: "#a9a9a9",
|
||||
darkgrey: "#a9a9a9",
|
||||
lightgray: "#d3d3d3",
|
||||
lightgrey: "#d3d3d3",
|
||||
dimgray: "#696969",
|
||||
dimgrey: "#696969",
|
||||
whitesmoke: "#f5f5f5",
|
||||
gainsboro: "#dcdcdc",
|
||||
darkblue: "#00008b",
|
||||
darkgreen: "#006400",
|
||||
darkred: "#8b0000",
|
||||
slategray: "#708090",
|
||||
slategrey: "#708090",
|
||||
steelblue: "#4682b4",
|
||||
royalblue: "#4169e1",
|
||||
skyblue: "#87ceeb",
|
||||
lightblue: "#add8e6",
|
||||
};
|
||||
|
||||
const HEX = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
const RGB = /^rgba?\(/i;
|
||||
|
||||
/** A colour Forme can parse (hex or rgb/rgba), or `undefined` when it can't be read. */
|
||||
export function toColor(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (HEX.test(trimmed)) {
|
||||
// Forme reads 3, 6 and 8 digits; spell out the 4-digit form.
|
||||
if (trimmed.length === 5) {
|
||||
const [r, g, b, a] = trimmed.slice(1);
|
||||
return `#${r}${r}${g}${g}${b}${b}${a}${a}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
if (RGB.test(trimmed)) {
|
||||
// Forme's parser wants rgba() to carry four numbers and rgb() three; normalise commas and percentages.
|
||||
const parts = trimmed
|
||||
.replace(/^rgba?\(|\)$/gi, "")
|
||||
.split(/[\s,/]+/)
|
||||
.filter(Boolean)
|
||||
.map((part, index) => {
|
||||
if (!part.endsWith("%")) return part;
|
||||
const percent = Number(part.slice(0, -1));
|
||||
return String(index < 3 ? (percent * 255) / 100 : percent / 100);
|
||||
});
|
||||
if (parts.length === 3) return `rgb(${parts.join(", ")})`;
|
||||
if (parts.length === 4) return `rgba(${parts.join(", ")})`;
|
||||
return undefined;
|
||||
}
|
||||
return NAMED_COLORS[trimmed.toLowerCase()];
|
||||
}
|
||||
|
||||
export type Rgb = readonly [number, number, number];
|
||||
|
||||
export const WHITE: Rgb = [255, 255, 255];
|
||||
|
||||
/** Channels and alpha of a colour in a form `toColor` returns. */
|
||||
export function parseColor(color: string): { rgb: Rgb; alpha: number } | undefined {
|
||||
if (HEX.test(color)) {
|
||||
const digits = color.slice(1);
|
||||
const full = digits.length <= 4 ? [...digits].map((digit) => digit + digit).join("") : digits;
|
||||
const channel = (index: number) => Number.parseInt(full.slice(index * 2, index * 2 + 2), 16);
|
||||
return { rgb: [channel(0), channel(1), channel(2)], alpha: full.length === 8 ? channel(3) / 255 : 1 };
|
||||
}
|
||||
const match = /^rgba?\(([^)]*)\)$/i.exec(color);
|
||||
if (!match?.[1]) return undefined;
|
||||
const [r = 0, g = 0, b = 0, a = 1] = match[1].split(/\s*,\s*/).map(Number);
|
||||
return { rgb: [r, g, b], alpha: a };
|
||||
}
|
||||
|
||||
const hex = (rgb: Rgb) =>
|
||||
`#${rgb
|
||||
.map((channel) =>
|
||||
Math.round(Math.min(255, Math.max(0, channel)))
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("")}`;
|
||||
|
||||
/**
|
||||
* Forme 0.25 paints translucent colours opaque, so each is mixed with what it's drawn over: its own background, the
|
||||
* nearest opaque background behind it, or the page. Exact on solid backdrops, which is where templates use them.
|
||||
* Returns the style and the backdrop its children are drawn over.
|
||||
*/
|
||||
export function flattenAlpha(style: FormeStyle, backdrop: Rgb): { style: FormeStyle; backdrop: Rgb } {
|
||||
const next: Record<string, unknown> = { ...style };
|
||||
const mix = (color: unknown, behind: Rgb) => {
|
||||
const parsed = typeof color === "string" ? parseColor(color) : undefined;
|
||||
if (!parsed || parsed.alpha >= 1) return { color, rgb: parsed?.rgb };
|
||||
const blend = (i: 0 | 1 | 2) => parsed.rgb[i] * parsed.alpha + behind[i] * (1 - parsed.alpha);
|
||||
const rgb: Rgb = [blend(0), blend(1), blend(2)];
|
||||
return { color: parsed.alpha <= 0 ? undefined : hex(rgb), rgb: parsed.alpha <= 0 ? undefined : rgb };
|
||||
};
|
||||
let own = backdrop;
|
||||
if (next.backgroundColor !== undefined) {
|
||||
const { color, rgb } = mix(next.backgroundColor, backdrop);
|
||||
if (color === undefined) delete next.backgroundColor;
|
||||
else next.backgroundColor = color;
|
||||
if (rgb) own = rgb;
|
||||
}
|
||||
for (const key of COLOR_KEYS) {
|
||||
if (key === "backgroundColor" || next[key] === undefined) continue;
|
||||
const { color } = mix(next[key], own);
|
||||
if (color === undefined) delete next[key];
|
||||
else next[key] = color;
|
||||
}
|
||||
return { style: next as FormeStyle, backdrop: own };
|
||||
}
|
||||
|
||||
const COLOR_KEYS = new Set([
|
||||
"color",
|
||||
"backgroundColor",
|
||||
"borderColor",
|
||||
"borderTopColor",
|
||||
"borderRightColor",
|
||||
"borderBottomColor",
|
||||
"borderLeftColor",
|
||||
]);
|
||||
|
||||
const LENGTH_KEYS = new Set([
|
||||
"width",
|
||||
"height",
|
||||
"minWidth",
|
||||
"minHeight",
|
||||
"maxWidth",
|
||||
"maxHeight",
|
||||
"flexBasis",
|
||||
"top",
|
||||
"right",
|
||||
"bottom",
|
||||
"left",
|
||||
"gap",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"padding",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"paddingHorizontal",
|
||||
"paddingVertical",
|
||||
"margin",
|
||||
"marginTop",
|
||||
"marginRight",
|
||||
"marginBottom",
|
||||
"marginLeft",
|
||||
"marginHorizontal",
|
||||
"marginVertical",
|
||||
"borderWidth",
|
||||
"borderTopWidth",
|
||||
"borderRightWidth",
|
||||
"borderBottomWidth",
|
||||
"borderLeftWidth",
|
||||
"borderRadius",
|
||||
"borderTopLeftRadius",
|
||||
"borderTopRightRadius",
|
||||
"borderBottomRightRadius",
|
||||
"borderBottomLeftRadius",
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
]);
|
||||
|
||||
// Offsets in percent are placed by `to-forme.tsx`, which knows the element's parent.
|
||||
const PERCENT_KEYS = new Set([
|
||||
"width",
|
||||
"height",
|
||||
"minWidth",
|
||||
"minHeight",
|
||||
"maxWidth",
|
||||
"maxHeight",
|
||||
"flexBasis",
|
||||
"left",
|
||||
"right",
|
||||
]);
|
||||
|
||||
// Numbers Forme reads as react-pdf does.
|
||||
// `aspectRatio` is read by `to-forme.tsx` for pictures, like `objectFit`.
|
||||
const NUMBER_KEYS = new Set([
|
||||
"flex",
|
||||
"flexGrow",
|
||||
"flexShrink",
|
||||
"opacity",
|
||||
"minWidowLines",
|
||||
"minOrphanLines",
|
||||
"aspectRatio",
|
||||
]);
|
||||
|
||||
// Keywords Forme reads as react-pdf does; anything else is reported.
|
||||
const KEYWORD_KEYS: Record<string, ReadonlySet<string>> = {
|
||||
display: new Set(["flex", "grid"]),
|
||||
flexDirection: new Set(["row", "column", "row-reverse", "column-reverse"]),
|
||||
flexWrap: new Set(["nowrap", "wrap", "wrap-reverse"]),
|
||||
justifyContent: new Set(["flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly"]),
|
||||
alignItems: new Set(["flex-start", "flex-end", "center", "stretch", "baseline"]),
|
||||
alignSelf: new Set(["flex-start", "flex-end", "center", "stretch", "baseline"]),
|
||||
alignContent: new Set([
|
||||
"flex-start",
|
||||
"flex-end",
|
||||
"center",
|
||||
"space-between",
|
||||
"space-around",
|
||||
"space-evenly",
|
||||
"stretch",
|
||||
]),
|
||||
position: new Set(["relative", "absolute"]),
|
||||
fontStyle: new Set(["normal", "italic", "oblique"]),
|
||||
textAlign: new Set(["left", "center", "right", "justify"]),
|
||||
overflow: new Set(["visible", "hidden"]),
|
||||
direction: new Set(["ltr", "rtl", "auto"]),
|
||||
textOverflow: new Set(["wrap", "ellipsis", "clip"]),
|
||||
hyphens: new Set(["none", "manual", "auto"]),
|
||||
lineBreaking: new Set(["optimal", "greedy"]),
|
||||
};
|
||||
|
||||
// Free-form strings Forme parses itself.
|
||||
const STRING_KEYS = new Set(["transform", "transformOrigin", "lang", "objectFit"]);
|
||||
|
||||
// CSS-wide keywords: each asks for the default, which is what leaving the property out gives.
|
||||
const CSS_WIDE = new Set(["initial", "inherit", "unset", "revert", "revert-layer"]);
|
||||
|
||||
// Keywords CSS allows that Forme spells differently.
|
||||
const KEYWORD_ALIASES: Record<string, string> = { start: "flex-start", end: "flex-end", block: "flex" };
|
||||
|
||||
// Accepted without effect; they're layout hints Forme handles its own way.
|
||||
const IGNORED = new Set([
|
||||
"shadowColor",
|
||||
"shadowWidth",
|
||||
"borderStyle",
|
||||
"borderTopStyle",
|
||||
"borderRightStyle",
|
||||
"borderBottomStyle",
|
||||
"borderLeftStyle",
|
||||
]);
|
||||
|
||||
const TEXT_DECORATIONS = new Set(["none", "underline", "line-through"]);
|
||||
const TEXT_TRANSFORMS = new Set(["none", "uppercase", "lowercase", "capitalize"]);
|
||||
|
||||
/** Converts one flattened react-pdf style for Forme. `context.fontSize` is the inherited size, for em and line height. */
|
||||
export function toFormeStyle(input: Style, context: Context): ConvertedStyle {
|
||||
const style: Record<string, unknown> = {};
|
||||
const dropped: string[] = [];
|
||||
const source = input as Record<string, unknown>;
|
||||
const fontSize = toPoints(source.fontSize, context);
|
||||
const ownFontSize = typeof fontSize === "number" ? fontSize : context.fontSize;
|
||||
const local = { ...context, fontSize: ownFontSize };
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined || value === null || value === "" || key.startsWith("@media")) continue;
|
||||
|
||||
if (key === "display" && value === "none") continue;
|
||||
if (typeof value === "string" && CSS_WIDE.has(value.trim())) continue;
|
||||
if (key === "fontSize") {
|
||||
if (typeof fontSize === "number") style.fontSize = fontSize;
|
||||
continue;
|
||||
}
|
||||
if (key === "fontFamily") {
|
||||
const families = (Array.isArray(value) ? value : [value]).filter(
|
||||
(family): family is string => typeof family === "string" && family.length > 0,
|
||||
);
|
||||
// Forme names the standard serif "Times" where react-pdf and the font list say "Times-Roman".
|
||||
if (families.length > 0)
|
||||
style.fontFamily = families.map((family) => (family === "Times-Roman" ? "Times" : family)).join(", ");
|
||||
continue;
|
||||
}
|
||||
if (key === "fontWeight") {
|
||||
const weight = toFontWeight(value);
|
||||
if (weight === undefined) dropped.push(key);
|
||||
else style.fontWeight = weight;
|
||||
continue;
|
||||
}
|
||||
if (key === "lineHeight") {
|
||||
// Unitless is a multiplier, as in react-pdf; a length is absolute, and Forme wants the multiplier.
|
||||
if (typeof value === "number" || (typeof value === "string" && /^-?\d*\.?\d+$/.test(value.trim()))) {
|
||||
style.lineHeight = Number(value);
|
||||
} else if (typeof value === "string" && value.trim().endsWith("%")) {
|
||||
style.lineHeight = Number(value.trim().slice(0, -1)) / 100;
|
||||
} else {
|
||||
const points = toPoints(value, local);
|
||||
if (typeof points === "number" && ownFontSize > 0) style.lineHeight = points / ownFontSize;
|
||||
else dropped.push(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (COLOR_KEYS.has(key)) {
|
||||
const color = toColor(value);
|
||||
if (color === undefined) dropped.push(key);
|
||||
else style[key] = color;
|
||||
continue;
|
||||
}
|
||||
if (LENGTH_KEYS.has(key)) {
|
||||
if (value === "auto") {
|
||||
// Auto margins push boxes apart; any other auto length is the default.
|
||||
if (key.startsWith("margin")) style[key] = "auto";
|
||||
continue;
|
||||
}
|
||||
// Shorthands with several values ("4 8") are expanded by Forme itself.
|
||||
if (typeof value === "string" && /\s/.test(value.trim())) {
|
||||
style[key] = value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => toPoints(part, local))
|
||||
.join(" ");
|
||||
continue;
|
||||
}
|
||||
const points = toPoints(value, local);
|
||||
// Forme takes percentages for box sizes only.
|
||||
if (points === undefined || (typeof points === "string" && !PERCENT_KEYS.has(key))) dropped.push(key);
|
||||
else style[key] = points;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
key === "border" ||
|
||||
key === "borderTop" ||
|
||||
key === "borderRight" ||
|
||||
key === "borderBottom" ||
|
||||
key === "borderLeft"
|
||||
) {
|
||||
style[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (key === "textDecoration") {
|
||||
const decoration = String(value)
|
||||
.split(/\s+/)
|
||||
.find((part) => TEXT_DECORATIONS.has(part));
|
||||
if (decoration) style.textDecoration = decoration;
|
||||
continue;
|
||||
}
|
||||
if (key === "textTransform") {
|
||||
// react-pdf's `upperfirst` is closest to `capitalize` on a single word.
|
||||
const transform = value === "upperfirst" ? "capitalize" : value;
|
||||
if (TEXT_TRANSFORMS.has(String(transform))) style.textTransform = transform;
|
||||
else dropped.push(key);
|
||||
continue;
|
||||
}
|
||||
if (NUMBER_KEYS.has(key)) {
|
||||
const number = Number(value);
|
||||
if (Number.isFinite(number)) style[key] = number;
|
||||
else dropped.push(key);
|
||||
continue;
|
||||
}
|
||||
const keywords = KEYWORD_KEYS[key];
|
||||
if (keywords) {
|
||||
const keyword = String(value).trim();
|
||||
const spelled = KEYWORD_ALIASES[keyword] ?? keyword;
|
||||
if (keywords.has(spelled)) style[key] = spelled;
|
||||
else dropped.push(key);
|
||||
continue;
|
||||
}
|
||||
if (STRING_KEYS.has(key)) {
|
||||
style[key] = String(value);
|
||||
continue;
|
||||
}
|
||||
if (IGNORED.has(key)) continue;
|
||||
dropped.push(key);
|
||||
}
|
||||
|
||||
return { style: style as FormeStyle, dropped, hidden: source.display === "none" };
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
import type { FormeDocument, Style as FormeStyle } from "@formepdf/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { LoadedImage } from "./images";
|
||||
import type { PageSize } from "./primitives";
|
||||
import type { HostElement, HostNode } from "./reconciler";
|
||||
import type { Rgb } from "./style";
|
||||
import type { Style, StyleProp } from "./style-types";
|
||||
import {
|
||||
Document as FormeDocumentElement,
|
||||
Fixed as FormeFixed,
|
||||
Image as FormeImage,
|
||||
Page as FormePage,
|
||||
Svg as FormeSvg,
|
||||
Text as FormeText,
|
||||
View as FormeView,
|
||||
serialize,
|
||||
} from "@formepdf/react";
|
||||
import { cloneElement, createElement, isValidElement } from "react";
|
||||
import { NODE_SOURCE_PREFIX, RESUME_NODE_PROP } from "../page-map";
|
||||
import { HOST } from "./primitives";
|
||||
import { flattenAlpha, flattenStyle, toFormeStyle, toPoints, WHITE } from "./style";
|
||||
|
||||
// A4 in points, the size used for `vw`/`vh` until a page says otherwise.
|
||||
const DEFAULT_PAGE = { width: 595.28, height: 841.89 };
|
||||
/** Free-form pages start this tall; `renderResume` measures the content and renders again at its height. */
|
||||
export const FREE_FORM_MEASURE_HEIGHT = 14_400;
|
||||
|
||||
type SourceLocation = { file: string; line: number; column: number };
|
||||
|
||||
type Edges = { top: number; right: number; bottom: number; left: number };
|
||||
|
||||
/** Source location of a repeated page background, so free-form measuring can skip it. */
|
||||
export const FIXED_SOURCE = "rr-fixed";
|
||||
|
||||
type Context = {
|
||||
fontSize: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
inText: boolean;
|
||||
/** The pictures, loaded by `renderResume`, by source. */
|
||||
images: ReadonlyMap<string, LoadedImage>;
|
||||
/** Whether some ancestor lays its children out in a row. */
|
||||
insideRow: boolean;
|
||||
/** The parent's padding: Forme places absolute boxes inside it, react-pdf over it. */
|
||||
parentPadding: Edges;
|
||||
/** The opaque colour behind the element being converted (see `flattenAlpha`). */
|
||||
backdrop: Rgb;
|
||||
/** The page's margin while converting its direct children; undefined deeper down. */
|
||||
pageMargin: Edges | undefined;
|
||||
/** Applied to every text run: Forme hyphenates per run, by its language. */
|
||||
textDefaults: FormeStyle;
|
||||
warnings: Set<string>;
|
||||
sourceMap: WeakMap<object, SourceLocation>;
|
||||
};
|
||||
|
||||
const PADDING_KEYS = [
|
||||
"padding",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"paddingHorizontal",
|
||||
"paddingVertical",
|
||||
] as const;
|
||||
|
||||
const pageDimensions = (size: PageSize | undefined) => {
|
||||
if (size === "LETTER") return { width: 612, height: 792 };
|
||||
if (size && typeof size === "object") return { width: size.width, height: size.height ?? FREE_FORM_MEASURE_HEIGHT };
|
||||
return DEFAULT_PAGE;
|
||||
};
|
||||
|
||||
const pageSizeProp = (size: PageSize | undefined) => {
|
||||
if (size === "LETTER") return "Letter" as const;
|
||||
if (size && typeof size === "object") return pageDimensions(size);
|
||||
return "A4" as const;
|
||||
};
|
||||
|
||||
/** The page's padding, which Forme takes as the page margin. */
|
||||
function pageMargin(style: Style, context: Context) {
|
||||
const source = style as Record<string, unknown>;
|
||||
const length = (value: unknown) => {
|
||||
const points = toPoints(value, context);
|
||||
return typeof points === "number" ? points : 0;
|
||||
};
|
||||
const edges = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
const all = length(source.padding);
|
||||
edges.top = edges.right = edges.bottom = edges.left = all;
|
||||
if (source.paddingVertical !== undefined) edges.top = edges.bottom = length(source.paddingVertical);
|
||||
if (source.paddingHorizontal !== undefined) edges.left = edges.right = length(source.paddingHorizontal);
|
||||
if (source.paddingTop !== undefined) edges.top = length(source.paddingTop);
|
||||
if (source.paddingRight !== undefined) edges.right = length(source.paddingRight);
|
||||
if (source.paddingBottom !== undefined) edges.bottom = length(source.paddingBottom);
|
||||
if (source.paddingLeft !== undefined) edges.left = length(source.paddingLeft);
|
||||
return edges;
|
||||
}
|
||||
|
||||
const convertStyle = (styleProp: unknown, context: Context, element: HostElement) => {
|
||||
const flat = flattenStyle(styleProp as StyleProp);
|
||||
const converted = toFormeStyle(flat, context);
|
||||
for (const property of converted.dropped)
|
||||
context.warnings.add(`${element.type.slice(3)}: ${property} isn't supported`);
|
||||
const flattened = flattenAlpha(converted.style, context.backdrop);
|
||||
return { ...converted, backdrop: flattened.backdrop, style: fromPaddingBox(flattened.style, context.parentPadding) };
|
||||
};
|
||||
|
||||
type ParentStyle = { style: FormeStyle; backdrop: Rgb };
|
||||
|
||||
const childContext = (context: Context, parent: ParentStyle, inText = context.inText): Context => ({
|
||||
...context,
|
||||
pageMargin: undefined,
|
||||
fontSize: typeof parent.style.fontSize === "number" ? parent.style.fontSize : context.fontSize,
|
||||
backdrop: parent.backdrop,
|
||||
parentPadding: paddingEdges(parent.style),
|
||||
insideRow: context.insideRow || parent.style.flexDirection === "row" || parent.style.flexDirection === "row-reverse",
|
||||
inText,
|
||||
});
|
||||
|
||||
const NO_EDGES: Edges = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
/** A converted style's padding per edge, in points. */
|
||||
function paddingEdges(style: FormeStyle): Edges {
|
||||
const source = style as Record<string, unknown>;
|
||||
const edges = { ...NO_EDGES };
|
||||
const all = source.padding;
|
||||
if (typeof all === "number") edges.top = edges.right = edges.bottom = edges.left = all;
|
||||
else if (typeof all === "string") {
|
||||
const [top = 0, right = top, bottom = top, left = right] = all.split(/\s+/).map(Number);
|
||||
Object.assign(edges, { top, right, bottom, left });
|
||||
}
|
||||
const number = (value: unknown) => (typeof value === "number" ? value : undefined);
|
||||
edges.top = number(source.paddingVertical) ?? edges.top;
|
||||
edges.bottom = number(source.paddingVertical) ?? edges.bottom;
|
||||
edges.left = number(source.paddingHorizontal) ?? edges.left;
|
||||
edges.right = number(source.paddingHorizontal) ?? edges.right;
|
||||
edges.top = number(source.paddingTop) ?? edges.top;
|
||||
edges.right = number(source.paddingRight) ?? edges.right;
|
||||
edges.bottom = number(source.paddingBottom) ?? edges.bottom;
|
||||
edges.left = number(source.paddingLeft) ?? edges.left;
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** Absolute offsets measured from the parent's padding box, as react-pdf measures them, for Forme's content box. */
|
||||
function fromPaddingBox(style: FormeStyle, padding: Edges): FormeStyle {
|
||||
if (style.position !== "absolute") return style;
|
||||
const next: Record<string, unknown> = { ...style };
|
||||
for (const edge of ["top", "right", "bottom", "left"] as const)
|
||||
if (typeof next[edge] === "number") next[edge] = (next[edge] as number) - padding[edge];
|
||||
return next as FormeStyle;
|
||||
}
|
||||
|
||||
const flowStyle = (props: Record<string, unknown>, style: FormeStyle): FormeStyle => {
|
||||
// Yoga places an absolute box against its parent; Forme, like CSS, against the nearest positioned ancestor.
|
||||
const next: FormeStyle = { position: "relative", ...style };
|
||||
if (props.wrap === false) next.wrap = false;
|
||||
if (props.break === true) next.breakBefore = true;
|
||||
if (typeof props.orphans === "number") next.minOrphanLines = props.orphans;
|
||||
if (typeof props.widows === "number") next.minWidowLines = props.widows;
|
||||
return next;
|
||||
};
|
||||
|
||||
const imageSource = (src: unknown): string | undefined => {
|
||||
if (typeof src === "string") return src;
|
||||
if (src && typeof src === "object" && "uri" in src && typeof src.uri === "string") return src.uri;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const number = (value: unknown, context: Context): number | undefined => {
|
||||
const points = toPoints(value, context);
|
||||
return typeof points === "number" ? points : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Forme 0.25 mislays a column's `rowGap` where the column breaks across pages (up to a page with a box of height
|
||||
* -1.8e308), so a column's gap becomes a top margin on every child after the first, which lays out the same.
|
||||
*/
|
||||
function spreadRowGap(
|
||||
style: FormeStyle,
|
||||
children: ReactNode[],
|
||||
context: Context,
|
||||
): { style: FormeStyle; children: ReactNode[] } {
|
||||
const direction = style.flexDirection ?? "column";
|
||||
const gap = style.rowGap ?? style.gap;
|
||||
if ((direction !== "column" && direction !== "column-reverse") || typeof gap !== "number" || gap === 0)
|
||||
return { style, children };
|
||||
const { rowGap: _rowGap, gap: _gap, ...rest } = style;
|
||||
const next: FormeStyle =
|
||||
style.gap !== undefined && style.columnGap === undefined ? { ...rest, columnGap: style.gap } : rest;
|
||||
let first = true;
|
||||
const result = children.map((child) => {
|
||||
if (!isValidElement<{ style?: FormeStyle }>(child)) return child;
|
||||
const childStyle = child.props.style;
|
||||
if (childStyle?.position === "absolute" || child.type === FormeFixed) return child;
|
||||
if (first) {
|
||||
first = false;
|
||||
return child;
|
||||
}
|
||||
const edge = direction === "column" ? "marginTop" : "marginBottom";
|
||||
const current = childStyle?.[edge];
|
||||
if (current === "auto") return child;
|
||||
const spaced = cloneElement(child, {
|
||||
style: { ...childStyle, [edge]: (typeof current === "number" ? current : 0) + gap },
|
||||
});
|
||||
const source = context.sourceMap.get(child);
|
||||
if (source) context.sourceMap.set(spaced, source);
|
||||
return spaced;
|
||||
});
|
||||
return { style: next, children: result };
|
||||
}
|
||||
|
||||
function convertChildren(children: HostNode[], context: Context): ReactNode[] {
|
||||
return children.map((child, index) => convertNode(child, context, index));
|
||||
}
|
||||
|
||||
function convertNode(node: HostNode, context: Context, key: number): ReactNode {
|
||||
if ("text" in node) return node.text;
|
||||
const { props } = node;
|
||||
|
||||
switch (node.type) {
|
||||
case HOST.view: {
|
||||
const converted = convertStyle(props.style, context, node);
|
||||
const { style } = converted;
|
||||
if (converted.hidden) return null;
|
||||
const spread = spreadRowGap(style, convertChildren(node.children, childContext(context, converted)), context);
|
||||
const children = spread.children;
|
||||
const viewStyle = flowStyle(props, spread.style);
|
||||
// Forme 0.25 gives a row within a row a box at y -1.8e308 on the next page when the outer row splits, which
|
||||
// wrecks the rest of the document. Rows within rows are headings, list items and icon-label pairs, which are
|
||||
// better kept whole anyway.
|
||||
if (context.insideRow && (style.flexDirection === "row" || style.flexDirection === "row-reverse"))
|
||||
viewStyle.wrap = false;
|
||||
const bordered = insetBorder(viewStyle, children);
|
||||
const element = createElement(
|
||||
FormeView,
|
||||
{
|
||||
key,
|
||||
style: bordered.style,
|
||||
...(typeof props.bookmark === "string" ? { bookmark: props.bookmark } : {}),
|
||||
},
|
||||
...bordered.children,
|
||||
);
|
||||
tagNode(element, props, context);
|
||||
if (props.fixed !== true) return shrinkToFit(placePercentOffset(element, style, context), context);
|
||||
// A repeated view nested in a column stays with the column: its fragments already paint on each page.
|
||||
if (!context.pageMargin) return element;
|
||||
return createElement(
|
||||
FormeFixed,
|
||||
{ key, position: "header" },
|
||||
fixedOnPage(element, style, context, context.pageMargin),
|
||||
);
|
||||
}
|
||||
case HOST.text:
|
||||
case HOST.link: {
|
||||
const converted = convertStyle(props.style, context, node);
|
||||
const { style } = converted;
|
||||
if (converted.hidden) return null;
|
||||
const href = node.type === HOST.link && typeof props.src === "string" ? props.src : undefined;
|
||||
const hasBlockChild = node.children.some(
|
||||
(child) => child.type !== "#text" && child.type !== HOST.text && child.type !== HOST.link,
|
||||
);
|
||||
// A link around views (a whole entry, a picture) becomes a linked view.
|
||||
if (href && hasBlockChild && !context.inText) {
|
||||
const children = convertChildren(node.children, childContext(context, converted));
|
||||
const element = createElement(FormeView, { key, style: flowStyle(props, style), href }, ...children);
|
||||
tagNode(element, props, context);
|
||||
return element;
|
||||
}
|
||||
const children = convertChildren(node.children, childContext(context, converted, true));
|
||||
const element = createElement(
|
||||
FormeText,
|
||||
{ key, style: { ...context.textDefaults, ...flowStyle(props, style) }, ...(href ? { href } : {}) },
|
||||
...children,
|
||||
);
|
||||
tagNode(element, props, context);
|
||||
return element;
|
||||
}
|
||||
case HOST.image: {
|
||||
const image = context.images.get(imageSource(props.src) ?? "");
|
||||
if (!image) return null;
|
||||
const { style, hidden } = convertStyle(props.style, context, node);
|
||||
if (hidden) return null;
|
||||
return placePercentOffset(fitImage(image, style, key), style, context);
|
||||
}
|
||||
case HOST.svg: {
|
||||
const { style, hidden } = convertStyle(props.style, context, node);
|
||||
if (hidden) return null;
|
||||
const width = number(props.width, context) ?? (typeof style.width === "number" ? style.width : 12);
|
||||
const height = number(props.height, context) ?? (typeof style.height === "number" ? style.height : width);
|
||||
const opacity = typeof props.opacity === "number" ? props.opacity : undefined;
|
||||
return createElement(FormeSvg, {
|
||||
key,
|
||||
width,
|
||||
height,
|
||||
content: String(props.content ?? ""),
|
||||
style: opacity === undefined ? style : { ...style, opacity },
|
||||
...(typeof props.viewBox === "string" ? { viewBox: props.viewBox } : {}),
|
||||
});
|
||||
}
|
||||
default:
|
||||
context.warnings.add(`Unknown element ${node.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme 0.25 strokes a border centred on the box's edge, so half of it lands outside the box and a gap of the other
|
||||
* half shows inside. For a thick uniform border on a box of known size, the border's room becomes padding and the
|
||||
* border is drawn by an overlay inset by half its width, which puts the paint where CSS puts it. Thinner borders
|
||||
* are left alone: their error is under half a point.
|
||||
*/
|
||||
function insetBorder(style: FormeStyle, children: ReactNode[]): { style: FormeStyle; children: ReactNode[] } {
|
||||
const { borderWidth, borderColor, width, height } = style;
|
||||
const perSide = ["borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"].some(
|
||||
(key) => (style as Record<string, unknown>)[key] !== undefined,
|
||||
);
|
||||
if (
|
||||
typeof borderWidth !== "number" ||
|
||||
borderWidth < 1.5 ||
|
||||
perSide ||
|
||||
typeof borderColor !== "string" ||
|
||||
typeof width !== "number" ||
|
||||
typeof height !== "number"
|
||||
)
|
||||
return { style, children };
|
||||
|
||||
const padding = paddingEdges(style);
|
||||
const {
|
||||
borderWidth: _borderWidth,
|
||||
borderColor: _borderColor,
|
||||
padding: _padding,
|
||||
paddingHorizontal: _paddingHorizontal,
|
||||
paddingVertical: _paddingVertical,
|
||||
...rest
|
||||
} = style;
|
||||
const radius = typeof style.borderRadius === "number" ? style.borderRadius : 0;
|
||||
const half = borderWidth / 2;
|
||||
const overlay = createElement(FormeView, {
|
||||
key: "border",
|
||||
style: {
|
||||
position: "absolute",
|
||||
// Forme places absolute boxes against the content box, inside the padding.
|
||||
left: half - padding.left - borderWidth,
|
||||
top: half - padding.top - borderWidth,
|
||||
width: width - borderWidth,
|
||||
height: height - borderWidth,
|
||||
borderWidth,
|
||||
borderColor,
|
||||
...(radius > 0 ? { borderRadius: Math.max(0, radius - half) } : {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
style: {
|
||||
...rest,
|
||||
paddingTop: padding.top + borderWidth,
|
||||
paddingRight: padding.right + borderWidth,
|
||||
paddingBottom: padding.bottom + borderWidth,
|
||||
paddingLeft: padding.left + borderWidth,
|
||||
},
|
||||
children: [...children, overlay],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A picture in its box, as CSS `object-fit` places it: stretched (`fill`), whole and centred (`contain`,
|
||||
* `scale-down`), or filling the box and cropped (`cover`, `none`). The box carries the style; the bitmap sits inside.
|
||||
*/
|
||||
function fitImage(image: LoadedImage, style: FormeStyle, key: number): ReactElement {
|
||||
const { objectFit, aspectRatio, ...boxStyle } = style as FormeStyle & { objectFit?: string; aspectRatio?: number };
|
||||
let width = typeof boxStyle.width === "number" ? boxStyle.width : undefined;
|
||||
let height = typeof boxStyle.height === "number" ? boxStyle.height : undefined;
|
||||
const ratio = typeof aspectRatio === "number" && aspectRatio > 0 ? aspectRatio : image.width / image.height;
|
||||
if (width === undefined && height !== undefined) width = height * ratio;
|
||||
if (height === undefined && width !== undefined) height = width / ratio;
|
||||
if (width === undefined || height === undefined || objectFit === undefined || objectFit === "fill")
|
||||
return createElement(FormeImage, {
|
||||
key,
|
||||
src: image.src,
|
||||
style: boxStyle,
|
||||
...(width === undefined ? {} : { width }),
|
||||
...(height === undefined ? {} : { height }),
|
||||
});
|
||||
|
||||
const scaleX = width / image.width;
|
||||
const scaleY = height / image.height;
|
||||
const scale =
|
||||
objectFit === "cover"
|
||||
? Math.max(scaleX, scaleY)
|
||||
: objectFit === "none"
|
||||
? 1
|
||||
: objectFit === "scale-down"
|
||||
? Math.min(1, scaleX, scaleY)
|
||||
: Math.min(scaleX, scaleY);
|
||||
const drawnWidth = image.width * scale;
|
||||
const drawnHeight = image.height * scale;
|
||||
return createElement(
|
||||
FormeView,
|
||||
{ key, style: { position: "relative", ...boxStyle, width, height, overflow: "hidden" } },
|
||||
createElement(
|
||||
FormeView,
|
||||
{ style: { position: "absolute", left: (width - drawnWidth) / 2, top: (height - drawnHeight) / 2 } },
|
||||
createElement(FormeImage, { src: image.src, width: drawnWidth, height: drawnHeight }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme takes absolute offsets in points only. A percentage one (a picture centred with `left: "50%"`) becomes a
|
||||
* full-width absolute row where a spacer of that percentage of the parent's width comes first.
|
||||
*/
|
||||
function placePercentOffset(element: ReactElement, style: FormeStyle, context: Context): ReactElement {
|
||||
const percent = (value: unknown) => typeof value === "string" && value.endsWith("%");
|
||||
const fromLeft = percent(style.left);
|
||||
const fromRight = percent(style.right);
|
||||
if (!fromLeft && !fromRight) return element;
|
||||
const { top, bottom, left, right, ...rest } = flowStyleOf(element);
|
||||
if (style.position !== "absolute" || (fromLeft && fromRight)) {
|
||||
context.warnings.add("view: percentage offsets are only supported on one side of an absolute box");
|
||||
return cloneElement(element as ReactElement<{ style: FormeStyle }>, { style: rest });
|
||||
}
|
||||
const place: FormeStyle = {
|
||||
position: "absolute",
|
||||
flexDirection: fromLeft ? "row" : "row-reverse",
|
||||
alignItems: "flex-start",
|
||||
left: 0,
|
||||
right: 0,
|
||||
...(top === undefined ? {} : { top }),
|
||||
...(bottom === undefined ? {} : { bottom }),
|
||||
};
|
||||
const content = cloneElement(element as ReactElement<{ style: FormeStyle }>, {
|
||||
key: "content",
|
||||
style: { ...rest, position: "relative", flexShrink: 0 },
|
||||
});
|
||||
const source = context.sourceMap.get(element);
|
||||
if (source) context.sourceMap.set(content, source);
|
||||
return createElement(
|
||||
FormeView,
|
||||
{ key: element.key, style: place },
|
||||
createElement(FormeView, { key: "offset", style: { width: String(fromLeft ? left : right), flexShrink: 0 } }),
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme 0.25 gives an absolute box without a width zero width, where CSS and Yoga shrink it to its content. Such a
|
||||
* box goes in a transparent absolute row that reaches the far edge, and sizes to its content inside it.
|
||||
*/
|
||||
function shrinkToFit(element: ReactElement, context: Context): ReactElement {
|
||||
const style = flowStyleOf(element);
|
||||
if (style.position !== "absolute" || style.width !== undefined) return element;
|
||||
const fromLeft = style.left !== undefined;
|
||||
const fromRight = style.right !== undefined;
|
||||
if (fromLeft === fromRight) return element;
|
||||
const { top, bottom, left, right, ...rest } = flowStyleOf(element);
|
||||
const place: FormeStyle = {
|
||||
position: "absolute",
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: fromLeft ? "flex-start" : "flex-end",
|
||||
...(top === undefined ? {} : { top }),
|
||||
...(bottom === undefined ? {} : { bottom }),
|
||||
left: fromLeft ? (left ?? 0) : 0,
|
||||
right: fromRight ? (right ?? 0) : 0,
|
||||
};
|
||||
const content = cloneElement(element as ReactElement<{ style: FormeStyle }>, {
|
||||
key: "content",
|
||||
style: { ...rest, position: "relative" },
|
||||
});
|
||||
const source = context.sourceMap.get(element);
|
||||
if (source) context.sourceMap.set(content, source);
|
||||
return createElement(FormeView, { key: element.key, style: place }, content);
|
||||
}
|
||||
|
||||
const flowStyleOf = (element: ReactElement) => (element.props as { style: FormeStyle }).style;
|
||||
|
||||
/**
|
||||
* A view react-pdf repeats on every page. Forme repeats header bands, which take room from the page; an absolute view
|
||||
* takes none, so it sits in an empty band, placed against the page as react-pdf placed it.
|
||||
*/
|
||||
function fixedOnPage(element: ReactElement, style: FormeStyle, context: Context, margin: Edges): ReactNode {
|
||||
if (style.position !== "absolute") return element;
|
||||
const edge = (value: unknown) => (typeof value === "number" ? value : undefined);
|
||||
const length = (value: unknown, whole: number) =>
|
||||
typeof value === "string" && value.endsWith("%") ? (Number.parseFloat(value) / 100) * whole : edge(value);
|
||||
const { top, right, bottom, left, width, height, ...rest } = style;
|
||||
const w = length(width, context.pageWidth);
|
||||
const h = length(height, context.pageHeight);
|
||||
const x =
|
||||
edge(left) ?? (w !== undefined && edge(right) !== undefined ? context.pageWidth - (edge(right) ?? 0) - w : 0);
|
||||
const y =
|
||||
edge(top) ?? (h !== undefined && edge(bottom) !== undefined ? context.pageHeight - (edge(bottom) ?? 0) - h : 0);
|
||||
const boxHeight = h ?? context.pageHeight - y - (edge(bottom) ?? 0);
|
||||
// Forme 0.25 cuts a repeated box taller than the page's content area short, so a tall one is drawn in bands.
|
||||
const band = Math.max(1, context.pageHeight - margin.top - margin.bottom);
|
||||
const bands: ReactElement[] = [];
|
||||
for (let offset = 0; offset < boxHeight; offset += band) {
|
||||
const placed: FormeStyle = {
|
||||
...rest,
|
||||
position: "absolute",
|
||||
left: x - margin.left,
|
||||
top: y + offset - margin.top,
|
||||
width: w ?? context.pageWidth - x - (edge(right) ?? 0),
|
||||
height: Math.min(band, boxHeight - offset),
|
||||
};
|
||||
const fixed = cloneElement(element as ReactElement<{ style: FormeStyle }>, { key: offset, style: placed });
|
||||
context.sourceMap.set(fixed, { file: FIXED_SOURCE, line: 1, column: 1 });
|
||||
bands.push(fixed);
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
/** Remembers which Forme element draws a tagged block, so its box can be found in the layout afterwards. */
|
||||
function tagNode(element: object, props: Record<string, unknown>, context: Context) {
|
||||
const key = props[RESUME_NODE_PROP];
|
||||
if (typeof key === "string" && key.length > 0)
|
||||
context.sourceMap.set(element, { file: `${NODE_SOURCE_PREFIX}${key}`, line: 1, column: 1 });
|
||||
}
|
||||
|
||||
function convertPage(page: HostElement, context: Context, key: number): ReactNode {
|
||||
const size = page.props.size as PageSize | undefined;
|
||||
const dimensions = pageDimensions(size);
|
||||
const pageContext: Context = { ...context, pageWidth: dimensions.width, pageHeight: dimensions.height };
|
||||
const flat = flattenStyle(page.props.style as StyleProp);
|
||||
const margin = pageMargin(flat, pageContext);
|
||||
const rest = Object.fromEntries(
|
||||
Object.entries(flat).filter(([property]) => !(PADDING_KEYS as readonly string[]).includes(property)),
|
||||
) as Style;
|
||||
const converted = flattenAlpha(toFormeStyle(rest, pageContext).style, WHITE);
|
||||
const spread = spreadRowGap(
|
||||
converted.style,
|
||||
convertChildren(page.children, { ...childContext(pageContext, converted), pageMargin: margin }),
|
||||
pageContext,
|
||||
);
|
||||
converted.style = spread.style;
|
||||
const { children } = spread;
|
||||
const fixed = children.filter((child) => isValidElement(child) && child.type === FormeFixed);
|
||||
if (fixed.length === 0)
|
||||
return createElement(FormePage, { key, size: pageSizeProp(size), margin, style: converted.style }, ...children);
|
||||
|
||||
// Forme lays repeated bands out with the page's other children, so on a row page they'd take a column's place.
|
||||
// The page's own layout moves to a box around the flowing content, and the bands stay outside it.
|
||||
const pageStyle: Record<string, unknown> = {};
|
||||
const layoutStyle: Record<string, unknown> = { flexGrow: 1 };
|
||||
for (const [property, value] of Object.entries(converted.style))
|
||||
(PAGE_LAYOUT_KEYS.has(property) ? layoutStyle : pageStyle)[property] = value;
|
||||
return createElement(
|
||||
FormePage,
|
||||
{ key, size: pageSizeProp(size), margin, style: pageStyle as FormeStyle },
|
||||
...fixed,
|
||||
createElement(
|
||||
FormeView,
|
||||
{ key: "content", style: layoutStyle as FormeStyle },
|
||||
...children.filter((child) => !fixed.includes(child)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_LAYOUT_KEYS = new Set([
|
||||
"display",
|
||||
"flexDirection",
|
||||
"flexWrap",
|
||||
"justifyContent",
|
||||
"alignItems",
|
||||
"alignContent",
|
||||
"gap",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"gridTemplateColumns",
|
||||
"gridTemplateRows",
|
||||
]);
|
||||
|
||||
export type ConvertedDocument = { document: FormeDocument; warnings: string[] };
|
||||
|
||||
/**
|
||||
* Host tree → Forme document JSON. Tagged blocks get a source location Forme carries into its layout info, which
|
||||
* is how the page map finds them.
|
||||
*/
|
||||
export function toFormeDocument(
|
||||
tree: HostNode[],
|
||||
images: ReadonlyMap<string, LoadedImage> = new Map(),
|
||||
): ConvertedDocument {
|
||||
const root = tree.find((node): node is HostElement => node.type === HOST.document);
|
||||
if (!root) throw new Error("The resume didn't render a <Document>.");
|
||||
|
||||
const context: Context = {
|
||||
fontSize: 12,
|
||||
pageWidth: DEFAULT_PAGE.width,
|
||||
pageHeight: DEFAULT_PAGE.height,
|
||||
inText: false,
|
||||
parentPadding: NO_EDGES,
|
||||
insideRow: false,
|
||||
images,
|
||||
backdrop: WHITE,
|
||||
pageMargin: undefined,
|
||||
textDefaults: {
|
||||
hyphens: root.props.hyphenation === "auto" ? "auto" : "manual",
|
||||
...(typeof root.props.language === "string" ? { lang: root.props.language } : {}),
|
||||
},
|
||||
warnings: new Set(),
|
||||
sourceMap: new WeakMap(),
|
||||
};
|
||||
|
||||
const { props } = root;
|
||||
const pages = root.children
|
||||
.filter((child): child is HostElement => child.type === HOST.page)
|
||||
.map((page, index) => convertPage(page, context, index));
|
||||
const metadata: Record<string, string> = {};
|
||||
for (const [from, to] of [
|
||||
["title", "title"],
|
||||
["author", "author"],
|
||||
["subject", "subject"],
|
||||
["creator", "creator"],
|
||||
["language", "lang"],
|
||||
] as const) {
|
||||
const value = props[from];
|
||||
if (typeof value === "string" && value.length > 0) metadata[to] = value;
|
||||
}
|
||||
const element = createElement(FormeDocumentElement, metadata, ...pages);
|
||||
|
||||
// Forme's serializer looks tagged elements up here (its dev server fills it for JSX source locations).
|
||||
const scope = globalThis as { __formeSourceMap?: WeakMap<object, SourceLocation> };
|
||||
const previous = scope.__formeSourceMap;
|
||||
scope.__formeSourceMap = context.sourceMap;
|
||||
try {
|
||||
return { document: serialize(element), warnings: [...context.warnings] };
|
||||
} finally {
|
||||
if (previous) scope.__formeSourceMap = previous;
|
||||
else delete scope.__formeSourceMap;
|
||||
}
|
||||
}
|
||||
@@ -11,23 +11,22 @@ import {
|
||||
resolveLegacyFontAlias,
|
||||
sortFontWeights,
|
||||
} from "@reactive-resume/fonts";
|
||||
import { isCJKLocale } from "@reactive-resume/utils/locale";
|
||||
import { Font } from "#react-pdf-renderer";
|
||||
|
||||
type FontWeightRange = {
|
||||
lowest: number;
|
||||
highest: number;
|
||||
};
|
||||
|
||||
const registeredFontVariants = new Set<string>();
|
||||
const fallbackFontFamily = "IBM Plex Serif";
|
||||
const cjkLetterRegex = cjkLetters().toRegExp();
|
||||
const fontWeightValues = new Set<FontWeight>(["100", "200", "300", "400", "500", "600", "700", "800", "900"]);
|
||||
const preferredFallbackFontWeights = ["400", "700", "600", "500"] satisfies FontWeight[];
|
||||
|
||||
// `fontFamily` is widened to `string | string[]` so react-pdf can do
|
||||
// glyph-level font fallback for CJK characters (#2986).
|
||||
type PdfTypography = Omit<Typography, "body" | "heading"> & {
|
||||
/** A font face the document needs: its family, weight, style and where its bytes are. */
|
||||
export type PdfFontRequest = { family: string; weight: number; italic: boolean; src: string };
|
||||
|
||||
// `fontFamily` is widened to `string | string[]` so the engine can fall back per glyph, e.g. for CJK (#2986).
|
||||
export type PdfTypography = Omit<Typography, "body" | "heading"> & {
|
||||
body: Omit<Typography["body"], "fontFamily"> & { fontFamily: string | string[] };
|
||||
heading: Omit<Typography["heading"], "fontFamily"> & { fontFamily: string | string[] };
|
||||
};
|
||||
@@ -209,30 +208,18 @@ export const resumeContentScripts = (data: ResumeData): Set<Script> => {
|
||||
return scripts;
|
||||
};
|
||||
|
||||
export const registerFonts = (
|
||||
/**
|
||||
* Resolves the typography for the PDF and lists the font faces it needs: the body and heading weights in normal and
|
||||
* italic, each family's true Bold, and one Noto fallback per writing system the content uses (#2986). Standard PDF
|
||||
* families need no files.
|
||||
*/
|
||||
export const resolvePdfFonts = (
|
||||
typography: Typography,
|
||||
locale: Locale,
|
||||
hasCjkContent = false,
|
||||
scripts?: Set<Script>,
|
||||
): PdfTypography => {
|
||||
// CJK needs per-character line breaking. This must stay CJK-only: applying
|
||||
// it to Arabic (cursive, joined letters) or Thai (combining marks) would
|
||||
// break shaping, so non-CJK fallbacks below do not enable it.
|
||||
const needsCjkLineBreaking = isCJKLocale(locale) || hasCjkContent;
|
||||
|
||||
Font.registerHyphenationCallback((word) => {
|
||||
if (needsCjkLineBreaking) {
|
||||
if (word === " ") return ["\u200C "];
|
||||
// Only break at every character for words that contain CJK characters.
|
||||
// Latin/non-CJK words must stay intact even in a CJK-locale resume.
|
||||
if (cjkLetterRegex.test(word)) {
|
||||
return [...word].flatMap((l) => [l, ""]);
|
||||
}
|
||||
}
|
||||
|
||||
return [word];
|
||||
});
|
||||
|
||||
): { typography: PdfTypography; fonts: PdfFontRequest[] } => {
|
||||
const fonts = new Map<string, PdfFontRequest>();
|
||||
const pdfTypography = resolvePdfTypography(typography);
|
||||
const bodyFontFamily = pdfTypography.body.fontFamily;
|
||||
const headingFontFamily = pdfTypography.heading.fontFamily;
|
||||
@@ -248,15 +235,13 @@ export const registerFonts = (
|
||||
if (isStandardPdfFontFamily(family)) return;
|
||||
|
||||
const normalizedWeight = toFontWeight(weight);
|
||||
const fontStyle = italic ? "italic" : "normal";
|
||||
const key = `${family}:${normalizedWeight}:${fontStyle}`;
|
||||
if (registeredFontVariants.has(key)) return;
|
||||
const key = `${family}:${normalizedWeight}:${italic}`;
|
||||
if (fonts.has(key)) return;
|
||||
|
||||
const source = getWebFontSource(family, normalizedWeight, italic);
|
||||
if (!source) return;
|
||||
|
||||
Font.register({ family, src: source, fontWeight: Number(normalizedWeight), fontStyle });
|
||||
registeredFontVariants.add(key);
|
||||
fonts.set(key, { family, weight: Number(normalizedWeight), italic, src: source });
|
||||
};
|
||||
|
||||
for (const italic of [false, true]) {
|
||||
@@ -307,7 +292,7 @@ export const registerFonts = (
|
||||
|
||||
// Latin-only path: no fallback registered, return as-is.
|
||||
if (bodyFallbacks.length === 0 && headingFallbacks.length === 0) {
|
||||
return pdfTypography as PdfTypography;
|
||||
return { typography: pdfTypography as PdfTypography, fonts: [...fonts.values()] };
|
||||
}
|
||||
|
||||
const bodyStack: string | string[] = bodyFallbacks.length > 0 ? [bodyFontFamily, ...bodyFallbacks] : bodyFontFamily;
|
||||
@@ -315,8 +300,11 @@ export const registerFonts = (
|
||||
headingFallbacks.length > 0 ? [headingFontFamily, ...headingFallbacks] : headingFontFamily;
|
||||
|
||||
return {
|
||||
...pdfTypography,
|
||||
body: { ...pdfTypography.body, fontFamily: bodyStack },
|
||||
heading: { ...pdfTypography.heading, fontFamily: headingStack },
|
||||
typography: {
|
||||
...pdfTypography,
|
||||
body: { ...pdfTypography.body, fontFamily: bodyStack },
|
||||
heading: { ...pdfTypography.heading, fontFamily: headingStack },
|
||||
},
|
||||
fonts: [...fonts.values()],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { createElement } from "react";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { ResumeDocument } from "./document";
|
||||
import { resolveResumeRuntime } from "./semantic/resolve";
|
||||
|
||||
function resume(hyphenation: boolean, locale = "de-DE"): ResumeData {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
data.picture.hidden = true;
|
||||
data.basics.name = "Probe";
|
||||
data.basics.headline = "";
|
||||
data.basics.email = "";
|
||||
data.basics.phone = "";
|
||||
data.basics.location = "";
|
||||
data.basics.website.url = "";
|
||||
data.basics.customFields = [];
|
||||
data.metadata.page.locale = locale;
|
||||
data.metadata.typography.body.fontFamily = "Helvetica";
|
||||
data.metadata.typography.heading.fontFamily = "Helvetica";
|
||||
data.metadata.typography.hyphenation = hyphenation;
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
|
||||
data.summary.title = "Text";
|
||||
data.summary.content = "<p>Gewerbesteuerdurchführungsverordnung</p>";
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: {
|
||||
languageVersion: 1,
|
||||
text: "@version 1; section { width: 100pt; } paragraph, list-item-content { font-size: 12pt; text-align: justify; }",
|
||||
},
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
async function pdfText(data: ResumeData) {
|
||||
expect(resolveResumeRuntime({ data, template: "onyx", mode: "semantic" }).diagnostics).toEqual([]);
|
||||
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<
|
||||
typeof renderToBuffer
|
||||
>[0];
|
||||
const bytes = new Uint8Array(await renderToBuffer(element));
|
||||
const loading = getDocument({ data: bytes });
|
||||
try {
|
||||
const document = await loading.promise;
|
||||
const items: string[] = [];
|
||||
for (let number = 1; number <= document.numPages; number++) {
|
||||
const page = await document.getPage(number);
|
||||
items.push(...(await page.getTextContent()).items.flatMap((item) => ("str" in item ? [item.str] : [])));
|
||||
}
|
||||
return items.join(" ");
|
||||
} finally {
|
||||
await loading.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
describe("German PDF hyphenation", () => {
|
||||
it("hyphenates long German words only when enabled, retaining every letter", { timeout: 60_000 }, async () => {
|
||||
const disabled = await pdfText(resume(false));
|
||||
const enabled = await pdfText(resume(true));
|
||||
expect(disabled).not.toContain("-");
|
||||
expect(enabled).toContain("-");
|
||||
expect(enabled.replaceAll(/[\s-]/g, "")).toContain("Gewerbesteuerdurchführungsverordnung");
|
||||
});
|
||||
it.each([
|
||||
["bold", "<p><strong>Gewerbesteuerdurchführungsverordnung</strong></p>"],
|
||||
["inline link", '<p><a href="https://example.com">Gewerbesteuerdurchführungsverordnung</a></p>'],
|
||||
["list paragraph", "<ul><li><p>Gewerbesteuerdurchführungsverordnung</p></li></ul>"],
|
||||
["bare list text", "<ul><li>Gewerbesteuerdurchführungsverordnung</li></ul>"],
|
||||
["bare HTML text", "Gewerbesteuerdurchführungsverordnung"],
|
||||
])("hyphenates %s in rich text", { timeout: 60_000 }, async (_name, content) => {
|
||||
const data = resume(true);
|
||||
data.summary.content = content;
|
||||
const text = await pdfText(data);
|
||||
expect(text).toContain("-");
|
||||
expect(text.replaceAll(/[\s-]/g, "")).toContain("Gewerbesteuerdurchführungsverordnung");
|
||||
});
|
||||
|
||||
it("keeps callbacks isolated across overlapping enabled and disabled exports", { timeout: 60_000 }, async () => {
|
||||
const cases = [resume(true), resume(false), resume(true, "en-US"), resume(true), resume(false)];
|
||||
const original = structuredClone(cases);
|
||||
const texts = await Promise.all(cases.map(pdfText));
|
||||
for (const index of [0, 3]) expect(texts[index]).toContain("-");
|
||||
for (const index of [1, 2, 4]) expect(texts[index]).not.toContain("-");
|
||||
expect(cases).toEqual(original);
|
||||
});
|
||||
|
||||
it("shows authored soft hyphens only at a line break", { timeout: 60_000 }, async () => {
|
||||
const data = resume(true);
|
||||
data.summary.content = "<p>Gewerbesteuer­durchführungsverordnung</p>";
|
||||
const narrow = await pdfText(data);
|
||||
expect(narrow).toContain("Gewerbesteuer-");
|
||||
expect(narrow.replaceAll(/[\s-]/g, "")).toContain("Gewerbesteuerdurchführungsverordnung");
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 1, text: "@version 1;" } };
|
||||
const wide = await pdfText(data);
|
||||
expect(wide).toContain("Gewerbesteuerdurchführungsverordnung");
|
||||
expect(wide).not.toContain("-");
|
||||
expect(wide).not.toContain("\u00AD");
|
||||
});
|
||||
|
||||
it("hyphenates native text buckets inside bare table cells", { timeout: 60_000 }, async () => {
|
||||
const data = resume(true);
|
||||
data.summary.content = '<table style="width: 100pt"><tr><td>Gewerbesteuerdurchführungsverordnung</td></tr></table>';
|
||||
data.metadata.stylesheet = {
|
||||
mode: "legacy",
|
||||
source: { languageVersion: 1, text: "@version 1; section { width: 100pt; }" },
|
||||
};
|
||||
const text = await pdfText(data);
|
||||
expect(text).toContain("-");
|
||||
expect(text.replaceAll(/[\s-]/g, "")).toContain("Gewerbesteuerdurchführungsverordnung");
|
||||
});
|
||||
it.each([false, true])(
|
||||
"hyphenates plain company fields and standalone links (linked=%s)",
|
||||
{ timeout: 60_000 },
|
||||
async (linked) => {
|
||||
const data = resume(true);
|
||||
const item = data.sections.experience.items[0];
|
||||
if (!item) throw new Error("Expected sample experience.");
|
||||
data.sections.experience.items = [
|
||||
{
|
||||
...item,
|
||||
company: "Gewerbesteuerdurchführungsverordnung",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
description: "",
|
||||
roles: [],
|
||||
website: { url: linked ? "https://example.com" : "", label: "", inlineLink: true },
|
||||
},
|
||||
];
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: ["experience"], sidebar: [] }];
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1; section { width: 100pt; }" },
|
||||
};
|
||||
const text = await pdfText(data);
|
||||
expect(text).toContain("-");
|
||||
expect(text.replaceAll(/[\s-]/g, "")).toContain("Gewerbesteuerdurchführungsverordnung");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createHyphenationCallback } from "./hyphenation";
|
||||
|
||||
const german = createHyphenationCallback({ locale: "de-DE", automatic: true, cjk: false });
|
||||
|
||||
describe("document hyphenation", () => {
|
||||
it.each([
|
||||
["Softwareentwicklung", ["Soft", "ware", "ent", "wick", "lung"]],
|
||||
["Krankenhausverwaltung", ["Kran", "ken", "haus", "ver", "wal", "tung"]],
|
||||
["„Donaudampfschifffahrt.“", ["„Do", "nau", "dampf", "schiff", "fahrt.“"]],
|
||||
])("finds German breaks without changing %s", (word, parts) => {
|
||||
expect(german(word)).toEqual(parts);
|
||||
expect(german(word).join("")).toBe(word);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://example.com/Softwareentwicklung",
|
||||
"kontakt@Softwareentwicklung.de",
|
||||
"Softwareentwicklung.de",
|
||||
"Softwareentwicklung2",
|
||||
"software_entwicklung",
|
||||
"Software-Entwicklung",
|
||||
"Разработка",
|
||||
"تطوير",
|
||||
"การพัฒนา",
|
||||
"",
|
||||
" ",
|
||||
"42",
|
||||
"👩💻",
|
||||
])("leaves unmarked addresses, identifiers and other scripts intact: %s", (word) => {
|
||||
expect(german(word)).toEqual([word]);
|
||||
});
|
||||
|
||||
it("honors authored soft hyphens instead of adding automatic breaks", () => {
|
||||
expect(german("Software\u00ADentwicklung")).toEqual(["Software", "entwicklung"]);
|
||||
expect(german("\u00ADSoft\u00AD\u00ADware\u00AD")).toEqual(["Soft", "ware"]);
|
||||
expect(german("\u00AD\u00AD")).toEqual([""]);
|
||||
});
|
||||
|
||||
it("keeps decomposed combining marks with their letters", () => {
|
||||
const word = "A\u0308nderungsverfahren";
|
||||
const parts = german(word);
|
||||
expect(parts.join("")).toBe(word);
|
||||
expect(parts.length).toBeGreaterThan(1);
|
||||
expect(parts.every((part) => !/^\p{M}/u.test(part))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["de-DE", "de-AT", "de-CH"])("uses German patterns for %s", (locale) => {
|
||||
expect(createHyphenationCallback({ locale, automatic: true, cjk: false })("Softwareentwicklung")).toEqual(
|
||||
german("Softwareentwicklung"),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ locale: "de-DE", automatic: false },
|
||||
{ locale: "en-US", automatic: true },
|
||||
{ locale: "fr-FR", automatic: true },
|
||||
])("preserves previous behavior for $locale / enabled=$automatic", (options) => {
|
||||
const callback = createHyphenationCallback({ ...options, cjk: false });
|
||||
for (const word of ["Softwareentwicklung", "Soft\u00ADware", " "]) expect(callback(word)).toEqual([word]);
|
||||
});
|
||||
|
||||
it.each([false, true])("retains CJK break markers with automatic=%s", (automatic) => {
|
||||
const callback = createHyphenationCallback({ locale: "de-DE", automatic, cjk: true });
|
||||
expect(callback("中文")).toEqual(["中", "", "文", ""]);
|
||||
expect(callback(" ")).toEqual(["\u200C "]);
|
||||
expect(callback("تطوير")).toEqual(["تطوير"]);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { syllables } from "@react-pdf/hyphenate/de-1996";
|
||||
import { letters as cjkLetters } from "cjk-regex";
|
||||
|
||||
type HyphenationOptions = {
|
||||
locale: string;
|
||||
automatic: boolean;
|
||||
cjk: boolean;
|
||||
};
|
||||
|
||||
const cjkLetterRegex = cjkLetters().toRegExp();
|
||||
// Keep addresses, URLs, identifiers, hard hyphens and other scripts intact.
|
||||
const germanWord = /^([("'„“«‹]*)([\p{Script=Latin}][\p{Script=Latin}\p{M}]*)([.,;:!?…)"'“”’»›]*)$/u;
|
||||
|
||||
/** Each document owns its callback; concurrent exports never switch its language. */
|
||||
export function createHyphenationCallback({ locale, automatic, cjk }: HyphenationOptions) {
|
||||
const german = automatic && /^de(?:-|$)/i.test(locale);
|
||||
return (word: string): string[] => {
|
||||
if (cjk) {
|
||||
if (word === " ") return ["\u200C "];
|
||||
if (cjkLetterRegex.test(word)) return [...word].flatMap((letter) => [letter, ""]);
|
||||
}
|
||||
if (!german) return [word];
|
||||
if (word.includes("\u00AD")) {
|
||||
const parts = word.split("\u00AD").filter(Boolean);
|
||||
return parts.length > 0 ? parts : [""];
|
||||
}
|
||||
const match = germanWord.exec(word);
|
||||
if (!match) return [word];
|
||||
const [, prefix = "", content = "", suffix = ""] = match;
|
||||
const parts: string[] = [];
|
||||
for (const part of syllables(content)) {
|
||||
// A break must never separate a combining mark from its preceding letter.
|
||||
if (/^\p{M}/u.test(part) && parts.length > 0) parts[parts.length - 1] += part;
|
||||
else parts.push(part);
|
||||
}
|
||||
if (parts.length === 0) return [word];
|
||||
parts[0] = prefix + parts[0];
|
||||
parts[parts.length - 1] += suffix;
|
||||
return parts;
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { ElementInfo, LayoutInfo } from "@formepdf/core";
|
||||
|
||||
/**
|
||||
* Maps rendered PDF regions back to resume data, so the editor can outline the block a field
|
||||
* belongs to and select an entry when its line on the page is clicked.
|
||||
*
|
||||
* Templates tag the views that own a semantic node with `RESUME_NODE_PROP` (see
|
||||
* `templates/shared/primitives.tsx` and `templates/shared/sections.tsx`). React PDF keeps non-style
|
||||
* props on its layout nodes and hands the layout tree to `Document.onRender` as
|
||||
* `_INTERNAL__LAYOUT__DATA_`. That tree isn't a public API, so everything here reads it defensively.
|
||||
* `templates/shared/primitives.tsx` and `templates/shared/sections.tsx`). The Forme conversion gives
|
||||
* each tagged element a source location, `rr-node:<key>`, which Forme reports back with every box in
|
||||
* its layout info.
|
||||
*/
|
||||
|
||||
export const RESUME_NODE_PROP = "data-resume-node";
|
||||
@@ -31,14 +33,6 @@ export type PageMap = {
|
||||
nodes: PageMapNode[];
|
||||
};
|
||||
|
||||
type LayoutBox = { left?: number; top?: number; width?: number; height?: number };
|
||||
type LayoutNode = {
|
||||
type?: string;
|
||||
box?: LayoutBox;
|
||||
props?: Record<string, unknown>;
|
||||
children?: LayoutNode[];
|
||||
};
|
||||
|
||||
const decode = (value: string) => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
@@ -73,40 +67,30 @@ export const parseResumeNodeKey = (key: string): PageMapTarget | undefined => {
|
||||
}
|
||||
};
|
||||
|
||||
const finite = (value: number | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
||||
/** Source-location files that carry a page-map key: `rr-node:<semantic key>`. */
|
||||
export const NODE_SOURCE_PREFIX = "rr-node:";
|
||||
|
||||
/**
|
||||
* Walks React PDF's layout tree and returns page-relative boxes for every tagged header, section
|
||||
* and item. Child boxes in that tree are relative to their parent's box (React PDF translates by
|
||||
* each parent's `left`/`top` while painting), so offsets are summed on the way down.
|
||||
*
|
||||
* ponytail: CSS `transform` on a tagged node or its ancestors isn't applied; templates don't
|
||||
* transform blocks today. Apply the transform matrix here if one ever does.
|
||||
* Page-relative boxes for every tagged header, section and item, from Forme's layout info. A block
|
||||
* that breaks across pages has a box on each page it reaches.
|
||||
*/
|
||||
export const extractPageMap = (layout: unknown): PageMap => {
|
||||
const root = layout as LayoutNode | undefined;
|
||||
const pages: PageMap["pages"] = [];
|
||||
export const extractPageMap = (layout: LayoutInfo): PageMap => {
|
||||
const nodes: PageMapNode[] = [];
|
||||
|
||||
const visit = (node: LayoutNode, page: number, offsetX: number, offsetY: number) => {
|
||||
const box = node.box ?? {};
|
||||
const x = offsetX + finite(box.left);
|
||||
const y = offsetY + finite(box.top);
|
||||
const key = node.props?.[RESUME_NODE_PROP];
|
||||
|
||||
if (typeof key === "string") {
|
||||
const visit = (element: ElementInfo, page: number) => {
|
||||
const file = element.sourceLocation?.file;
|
||||
if (file?.startsWith(NODE_SOURCE_PREFIX)) {
|
||||
const key = file.slice(NODE_SOURCE_PREFIX.length);
|
||||
const target = parseResumeNodeKey(key);
|
||||
if (target) nodes.push({ ...target, key, page, x, y, width: finite(box.width), height: finite(box.height) });
|
||||
if (target)
|
||||
nodes.push({ ...target, key, page, x: element.x, y: element.y, width: element.width, height: element.height });
|
||||
}
|
||||
|
||||
for (const child of node.children ?? []) visit(child, page, x, y);
|
||||
for (const child of element.children) visit(child, page);
|
||||
};
|
||||
|
||||
for (const pageNode of root?.children ?? []) {
|
||||
const page = pages.length;
|
||||
pages.push({ width: finite(pageNode.box?.width), height: finite(pageNode.box?.height) });
|
||||
for (const child of pageNode.children ?? []) visit(child, page, 0, 0);
|
||||
}
|
||||
layout.pages.forEach((page, index) => {
|
||||
for (const element of page.elements) visit(element, index);
|
||||
});
|
||||
|
||||
return { pages, nodes };
|
||||
return { pages: layout.pages.map((page) => ({ width: page.width, height: page.height })), nodes };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ResolvedNodeStyle, ResolvedPageSize } from "@reactive-resume/resume/stylesheet";
|
||||
import type { Style } from "../forme/style-types";
|
||||
|
||||
type ResolvedPdfPageSize = ResolvedPageSize;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { Style } from "../forme/style-types";
|
||||
import type { TemplateStyleSlots } from "../templates/shared/types";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ResumeData, StyleIntent, StyleRule, StyleSlot } from "@reactive-resume/schema/resume/data";
|
||||
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { Style } from "../forme/style-types";
|
||||
import { escapeCssComment, escapeCssString, serializeGeneratedStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { styleRulesSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { getSectionStyleRuleContext } from "@reactive-resume/schema/resume/style-rules";
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import { createElement } from "react";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { renderToBuffer } from "#react-pdf-renderer";
|
||||
import { ResumeDocument } from "./document";
|
||||
import * as forme from "@formepdf/core";
|
||||
import { renderResume } from "./forme/render";
|
||||
|
||||
export type CreateResumePdfFileOptions = {
|
||||
data: ResumeData;
|
||||
@@ -13,21 +11,7 @@ export type CreateResumePdfFileOptions = {
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
};
|
||||
|
||||
export const createResumePdfFile = async ({
|
||||
data: input,
|
||||
filename,
|
||||
template,
|
||||
resolveSectionTitle,
|
||||
}: CreateResumePdfFileOptions): Promise<File> => {
|
||||
const data = parseResumeData(input);
|
||||
const document = createElement(ResumeDocument, {
|
||||
data,
|
||||
template: template ?? data.metadata.template,
|
||||
resolveSectionTitle,
|
||||
}) as Parameters<typeof renderToBuffer>[0];
|
||||
const buffer = await renderToBuffer(document);
|
||||
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
||||
bytes.set(buffer);
|
||||
|
||||
return new File([bytes], filename, { type: "application/pdf" });
|
||||
export const createResumePdfFile = async ({ filename, ...input }: CreateResumePdfFileOptions): Promise<File> => {
|
||||
const { pdf } = await renderResume(forme, input);
|
||||
return new File([pdf as Uint8Array<ArrayBuffer>], filename, { type: "application/pdf" });
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type {
|
||||
TemplateColorRoles,
|
||||
TemplateFeatureStyleSlots,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet } from "#react-pdf-renderer";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { PageMarginBackground } from "../shared/page-margin-background";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import {
|
||||
Heading,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
Text,
|
||||
} from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { SidebarBackground } from "../shared/sidebar-background";
|
||||
import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles";
|
||||
import { createIconSlot, useTemplateBase } from "../shared/template-base";
|
||||
|
||||
@@ -78,6 +78,13 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader,
|
||||
)}
|
||||
>
|
||||
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
|
||||
{!page.fullWidth && (
|
||||
<SidebarBackground
|
||||
end
|
||||
color={String(sidebarStyle?.backgroundColor ?? colors.primary)}
|
||||
width={`${metadata.layout.sidebarWidth}%`}
|
||||
/>
|
||||
)}
|
||||
<SemanticRegionView
|
||||
region="main"
|
||||
style={composeStyles(styles.mainColumn, {
|
||||
@@ -110,10 +117,6 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader,
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
<PageMarginBackground
|
||||
color={sidebarStyle?.backgroundColor ?? colors.primary}
|
||||
margin={metrics.page.paddingVertical}
|
||||
/>
|
||||
{sidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
<Section section={section} placement="sidebar" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
@@ -18,7 +18,6 @@ import { TemplateProvider } from "../shared/context";
|
||||
import { getFeaturedSummaryLayout } from "../shared/featured-summary";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { PageMarginBackground } from "../shared/page-margin-background";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import {
|
||||
Heading,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
Text,
|
||||
} from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { SidebarBackground } from "../shared/sidebar-background";
|
||||
import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles";
|
||||
import { useTemplateBase } from "../shared/template-base";
|
||||
|
||||
@@ -96,6 +96,12 @@ export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pag
|
||||
)}
|
||||
>
|
||||
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors} features={ditgarFeatures}>
|
||||
{showSidebar && (
|
||||
<SidebarBackground
|
||||
color={colors.sidebarBackground ?? colors.background}
|
||||
width={`${metadata.layout.sidebarWidth}%`}
|
||||
/>
|
||||
)}
|
||||
{showSidebar && (
|
||||
<View
|
||||
style={composeStyles(styles.sidebarColumn, {
|
||||
@@ -103,10 +109,6 @@ export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pag
|
||||
marginTop: -metrics.page.paddingVertical,
|
||||
})}
|
||||
>
|
||||
<PageMarginBackground
|
||||
color={colors.sidebarBackground ?? colors.background}
|
||||
margin={metrics.page.paddingVertical}
|
||||
/>
|
||||
{showHeader && <Header styles={styles} colors={colors} />}
|
||||
|
||||
{!page.fullWidth && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
@@ -10,7 +10,6 @@ import { getPrimaryTint } from "../shared/color-helpers";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { PageMarginBackground } from "../shared/page-margin-background";
|
||||
import { SemanticRegionView, SemanticTemplatePartView } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles";
|
||||
@@ -85,10 +84,6 @@ export const GlaliePage = ({ page, pageSize, pageMinHeightStyle, showHeader, pag
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
})}
|
||||
>
|
||||
<PageMarginBackground
|
||||
color={colors.sidebarBackground ?? colors.background}
|
||||
margin={metrics.page.paddingVertical}
|
||||
/>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
{!page.fullWidth && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet } from "#react-pdf-renderer";
|
||||
@@ -96,6 +96,9 @@ const useLaprasTemplate = (): LaprasTemplate => {
|
||||
const borderColor = "#CCCCCC";
|
||||
const pictureBorderRadius = Math.min(picture.borderRadius, 30);
|
||||
const headingNegativeMargin = metadata.typography.heading.fontSize + 6;
|
||||
// The heading sits on the card's top border. It's placed there absolutely, with the card's top padding keeping
|
||||
// its room: Forme loses a page when a box pulled above its parent by a negative margin starts one.
|
||||
const headingHeight = metadata.typography.heading.fontSize * metadata.typography.heading.lineHeight;
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
@@ -114,11 +117,13 @@ const useLaprasTemplate = (): LaprasTemplate => {
|
||||
borderRadius: pictureBorderRadius,
|
||||
backgroundColor: background,
|
||||
padding: metrics.gapX(1),
|
||||
paddingTop: metrics.gapX(1) - headingNegativeMargin + headingHeight + metrics.gapY(0.25),
|
||||
marginTop: Math.max(0, headingNegativeMargin - metrics.gapX(1)),
|
||||
},
|
||||
sectionHeading: {
|
||||
alignSelf: "flex-start",
|
||||
marginTop: -headingNegativeMargin,
|
||||
position: "absolute",
|
||||
top: metrics.gapX(1) - headingNegativeMargin,
|
||||
...r.anchorToStart(metrics.gapX(1)),
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.gapX(1),
|
||||
},
|
||||
@@ -185,6 +190,7 @@ const useLaprasTemplate = (): LaprasTemplate => {
|
||||
metadata,
|
||||
r.row,
|
||||
r.headerIdentity,
|
||||
r.anchorToStart,
|
||||
primary,
|
||||
metrics.gapY,
|
||||
metrics.page.paddingVertical,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet } from "#react-pdf-renderer";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ReactNode } from "react";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { Page, StyleSheet, View } from "#react-pdf-renderer";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { Picture, ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { getTemplateMetrics } from "./metrics";
|
||||
import type { createRtlStyleHelpers } from "./rtl";
|
||||
import { resolveBoldFontWeight } from "@reactive-resume/fonts";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplatePlacement } from "./styles";
|
||||
|
||||
const MIN_SECTION_COLUMNS = 1;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { CustomField } from "@reactive-resume/schema/resume/data";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { IconName } from "../../forme/icons";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import { View } from "#react-pdf-renderer";
|
||||
import { resolvedPdfFlowProps } from "../../semantic/adapter";
|
||||
import { useResolvedNode, useSemanticNodeKey, useSemanticNodeVisible } from "../../semantic/context";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { StyleInput } from "./styles";
|
||||
import { composeStyles } from "./styles";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { IconName } from "../../forme/icons";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import { resolveLevelDisplaySizes } from "@reactive-resume/schema/resume/level-display-sizes";
|
||||
import { View } from "#react-pdf-renderer";
|
||||
import { useRender } from "../../context";
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { View } from "#react-pdf-renderer";
|
||||
|
||||
type PageMarginBackgroundProps = {
|
||||
color: string;
|
||||
margin: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Page padding repeats on overflow pages; the negative top margin on each column
|
||||
* preserves its first-page position and is reset by React PDF when the column splits.
|
||||
* Extend only its paint through those margins, without painting translucent content twice.
|
||||
*/
|
||||
export const PageMarginBackground = ({ color, margin }: PageMarginBackgroundProps) => (
|
||||
<>
|
||||
<View
|
||||
fixed
|
||||
style={{ position: "absolute", top: -margin, height: margin, left: 0, right: 0, backgroundColor: color }}
|
||||
/>
|
||||
<View
|
||||
fixed
|
||||
style={{ position: "absolute", bottom: -margin, height: margin, left: 0, right: 0, backgroundColor: color }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
|
||||
const A4_PAGE_SIZE = {
|
||||
width: 595.28,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import { encode } from "fast-png";
|
||||
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { StyleInput } from "./styles";
|
||||
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
|
||||
import { Children, isValidElement } from "react";
|
||||
import { Image, View } from "#react-pdf-renderer";
|
||||
import { useRender } from "../../context";
|
||||
import { PhosphorIcon } from "../../forme/icons";
|
||||
import { resolvedPdfFlowProps, resolvedPdfTextProps } from "../../semantic/adapter";
|
||||
import {
|
||||
projectRenderedChildren,
|
||||
@@ -483,8 +483,9 @@ export const SemanticHeaderPicture = ({ style, ...props }: ComponentProps<typeof
|
||||
position: "absolute",
|
||||
left: -shadow.extent - borderInsets.left,
|
||||
top: -shadow.extent - borderInsets.top,
|
||||
right: -shadow.extent - borderInsets.right,
|
||||
bottom: -shadow.extent - borderInsets.bottom,
|
||||
// The engine sizes an image by its width and height, not by opposite offsets.
|
||||
...(typeof pictureStyle.width === "number" ? { width: pictureStyle.width + shadow.extent * 2 } : {}),
|
||||
...(typeof pictureStyle.height === "number" ? { height: pictureStyle.height + shadow.extent * 2 } : {}),
|
||||
opacity: pictureStyle.opacity ?? 1,
|
||||
}}
|
||||
/>
|
||||
@@ -508,8 +509,19 @@ export const SemanticHeaderPicture = ({ style, ...props }: ComponentProps<typeof
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// The bitmap's corners follow the inside of the frame's border.
|
||||
...(typeof pictureStyle.borderRadius === "number"
|
||||
? { borderRadius: Math.max(0, pictureStyle.borderRadius - Math.max(...Object.values(borderInsets))) }
|
||||
: {}),
|
||||
// In points when the frame's size is known, so the engine can fit the bitmap (`objectFit`) itself.
|
||||
width:
|
||||
typeof pictureStyle.width === "number"
|
||||
? pictureStyle.width - borderInsets.left - borderInsets.right
|
||||
: "100%",
|
||||
height:
|
||||
typeof pictureStyle.height === "number"
|
||||
? pictureStyle.height - borderInsets.top - borderInsets.bottom
|
||||
: "100%",
|
||||
margin: 0,
|
||||
marginTop: 0,
|
||||
marginRight: 0,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import { cloneElement, createElement } from "react";
|
||||
import { Text as PdfText, View } from "#react-pdf-renderer";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { StyleInput } from "./styles";
|
||||
import { NodeType } from "node-html-parser";
|
||||
import { parseFiniteNumber, parsePxValue, parseStyleFontSize } from "./icon-size";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { StyleInput } from "./styles";
|
||||
import { richTextMarkClassName } from "./rich-text-html";
|
||||
import { safeTextStyle } from "./safe-text-style";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import { cloneElement, isValidElement } from "react";
|
||||
import { View } from "#react-pdf-renderer";
|
||||
import { useRender } from "../../context";
|
||||
@@ -74,7 +74,7 @@ const applyRtlDirectionRecursively = (node: ReactNode): ReactNode => {
|
||||
};
|
||||
|
||||
export const RichText = ({ children, semanticField }: RichTextProps) => {
|
||||
const { metadata, rtl, hyphenationCallback } = useRender();
|
||||
const { metadata, rtl } = useRender();
|
||||
const parentNodeKey = useSemanticNodeKey();
|
||||
const fieldNodeKey =
|
||||
parentNodeKey && semanticField ? semanticNodeKeys.field(parentNodeKey, semanticField) : undefined;
|
||||
@@ -172,7 +172,6 @@ export const RichText = ({ children, semanticField }: RichTextProps) => {
|
||||
|
||||
return (
|
||||
<Html
|
||||
resetStyles
|
||||
{...resolvedPdfFlowProps(richTextResolved)}
|
||||
style={composeStyles(fieldResolved.style, richTextResolved.style)}
|
||||
renderers={{
|
||||
@@ -236,7 +235,6 @@ export const RichText = ({ children, semanticField }: RichTextProps) => {
|
||||
semanticStyle: resolved.style,
|
||||
textProps: {
|
||||
...resolvedPdfTextProps(resolved),
|
||||
hyphenationCallback,
|
||||
"data-resume-whitespace": props.element.getAttribute("data-resume-whitespace"),
|
||||
},
|
||||
rtl,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
|
||||
type RtlStyleHelpers = {
|
||||
rtl: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
|
||||
export const safeTextStyle = {
|
||||
minWidth: 0,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type {
|
||||
AwardItem,
|
||||
CertificationItem,
|
||||
@@ -18,8 +17,9 @@ import type {
|
||||
SummaryItem,
|
||||
VolunteerItem,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { ReactNode } from "react";
|
||||
import type { IconName } from "../../forme/icons";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { CombinedTextName } from "../../semantic/node-keys";
|
||||
import type { StyleInput, TemplatePlacement } from "./styles";
|
||||
import type { CustomItemSection, ItemSection } from "./types";
|
||||
@@ -207,6 +207,12 @@ export const getSectionHeadingTextStyle = (...styles: StyleInput[]): Style[] =>
|
||||
paddingRight: _paddingRight,
|
||||
paddingTop: _paddingTop,
|
||||
width: _width,
|
||||
// The container is the one placed; its text flows inside it.
|
||||
position: _position,
|
||||
top: _top,
|
||||
right: _right,
|
||||
bottom: _bottom,
|
||||
left: _left,
|
||||
...textStyle
|
||||
}) => textStyle,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { View } from "#react-pdf-renderer";
|
||||
import { useRender } from "../../context";
|
||||
|
||||
type SidebarBackgroundProps = {
|
||||
color: string;
|
||||
/** The sidebar's width, as a percentage of the page. */
|
||||
width: string;
|
||||
/** Whether the sidebar sits at the end of the line (right, or left in RTL) rather than the start. */
|
||||
end?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Paints the sidebar column's colour down the whole height of every page, margins included, so a column that ends or
|
||||
* continues on a later page still reads as one band. Render it as a direct child of the page.
|
||||
*/
|
||||
export const SidebarBackground = ({ color, width, end = false }: SidebarBackgroundProps) => {
|
||||
const { rtl } = useRender();
|
||||
const side = end !== rtl ? "right" : "left";
|
||||
return <View fixed style={{ position: "absolute", top: 0, bottom: 0, [side]: 0, width, backgroundColor: color }} />;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ResumeData, StyleIntent, StyleSlot } from "@reactive-resume/schema/resume/data";
|
||||
import type { SectionStyleRuleContext } from "@reactive-resume/schema/resume/style-rules";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import { getSectionStyleRuleContext, resolveStyleIntentForSlot } from "@reactive-resume/schema/resume/style-rules";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
|
||||
export type TemplatePlacement = "main" | "sidebar";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Style } from "../../forme/style-types";
|
||||
import type { TemplateIconSlot, TemplateStyleContext } from "./types";
|
||||
import { useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CustomSection as SchemaCustomSection } from "@reactive-resume/schema/resume/data";
|
||||
import type { Icon } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { PhosphorIcon } from "../../forme/icons";
|
||||
import type { StyleInput, TemplatePlacement } from "./styles";
|
||||
|
||||
export type TemplateColorRoles = {
|
||||
@@ -18,7 +18,7 @@ export type TemplateStyleContext = {
|
||||
|
||||
export type TemplateStyleSlot = StyleInput | ((context: TemplateStyleContext) => StyleInput);
|
||||
|
||||
export type TemplateIconProps = Omit<ComponentProps<typeof Icon>, "name">;
|
||||
export type TemplateIconProps = Omit<ComponentProps<typeof PhosphorIcon>, "name">;
|
||||
|
||||
export type TemplateIconSlot =
|
||||
| Partial<TemplateIconProps>
|
||||
|
||||
+190
-34
@@ -1,43 +1,199 @@
|
||||
import type { ComponentProps, ReactElement, ReactNode } from "react";
|
||||
import type { Html as RendererHtml } from "react-pdf-html";
|
||||
import { cloneElement, isValidElement } from "react";
|
||||
import { renderHtml } from "react-pdf-html";
|
||||
import { Link as RendererLink, Text as RendererText } from "#react-pdf-renderer";
|
||||
import { useRender } from "./context";
|
||||
import type { HTMLElement, Node } from "node-html-parser";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Style, ViewProps } from "./forme/primitives";
|
||||
import { NodeType, parse } from "node-html-parser";
|
||||
import { createElement, Fragment } from "react";
|
||||
import { Text, View } from "./forme/primitives";
|
||||
|
||||
type TextProps = ComponentProps<typeof RendererText>;
|
||||
type LinkProps = ComponentProps<typeof RendererLink>;
|
||||
type HtmlProps = ComponentProps<typeof RendererHtml>;
|
||||
export { Link, Text } from "./forme/primitives";
|
||||
|
||||
export function Text(props: TextProps) {
|
||||
const { hyphenationCallback } = useRender();
|
||||
return <RendererText hyphenationCallback={hyphenationCallback} {...props} />;
|
||||
/** A parsed HTML element, as rich-text renderers receive it. */
|
||||
export type HtmlElement = HTMLElement & {
|
||||
/** Position among its parent's children with the same tag. */
|
||||
indexOfType: number;
|
||||
/** Stylesheet rules that match it, then its inline `style`. */
|
||||
styles: Style[];
|
||||
};
|
||||
|
||||
export type HtmlRendererProps = { element: HtmlElement; style: Style[]; children: ReactNode };
|
||||
export type HtmlRenderer = (props: HtmlRendererProps) => ReactNode;
|
||||
|
||||
type HtmlProps = Omit<ViewProps, "children"> & {
|
||||
children: string;
|
||||
/** Per-tag renderers; tags without one render as a View or, when inline, a Text. */
|
||||
renderers?: Record<string, HtmlRenderer>;
|
||||
/** Styles by tag name (`p`) or class (`.mark`). */
|
||||
stylesheet?: Record<string, Style>;
|
||||
};
|
||||
|
||||
// Inline tags flow inside a line of text; everything else is a block.
|
||||
const INLINE_TAGS = new Set([
|
||||
"span",
|
||||
"br",
|
||||
"b",
|
||||
"strong",
|
||||
"i",
|
||||
"em",
|
||||
"u",
|
||||
"s",
|
||||
"strike",
|
||||
"mark",
|
||||
"code",
|
||||
"sub",
|
||||
"sup",
|
||||
"q",
|
||||
"cite",
|
||||
"abbr",
|
||||
"dfn",
|
||||
"small",
|
||||
"label",
|
||||
]);
|
||||
|
||||
// What browsers draw without a stylesheet, minus spacing and sizes: templates own those.
|
||||
const TAG_STYLES: Record<string, Style> = {
|
||||
h1: { fontWeight: "bold" },
|
||||
h2: { fontWeight: "bold" },
|
||||
h3: { fontWeight: "bold" },
|
||||
h4: { fontWeight: "bold" },
|
||||
h5: { fontWeight: "bold" },
|
||||
h6: { fontWeight: "bold" },
|
||||
b: { fontWeight: "bold" },
|
||||
strong: { fontWeight: "bold" },
|
||||
i: { fontStyle: "italic" },
|
||||
em: { fontStyle: "italic" },
|
||||
cite: { fontStyle: "italic" },
|
||||
s: { textDecoration: "line-through" },
|
||||
strike: { textDecoration: "line-through" },
|
||||
u: { textDecoration: "underline" },
|
||||
a: { textDecoration: "underline" },
|
||||
li: { display: "flex", flexDirection: "row" },
|
||||
};
|
||||
|
||||
const camelCase = (property: string) =>
|
||||
property.trim().replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
||||
|
||||
/** An inline `style` attribute as a style object. Values stay CSS strings; the Forme conversion reads them. */
|
||||
function parseInlineStyle(value: string | undefined): Style | undefined {
|
||||
if (!value?.trim()) return undefined;
|
||||
const style: Record<string, unknown> = {};
|
||||
for (const declaration of value.split(";")) {
|
||||
const colon = declaration.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const property = camelCase(declaration.slice(0, colon));
|
||||
const text = declaration.slice(colon + 1).trim();
|
||||
if (!property || !text) continue;
|
||||
if (property === "fontFamily")
|
||||
style.fontFamily = text.split(",").map((family) => family.trim().replace(/["']/g, ""));
|
||||
else if (property === "background") style.backgroundColor = text;
|
||||
else style[property] = text;
|
||||
}
|
||||
return style as Style;
|
||||
}
|
||||
|
||||
export function Link(props: LinkProps) {
|
||||
const { hyphenationCallback } = useRender();
|
||||
// React PDF lays out text-only links as Text nodes. Its Link type omits this
|
||||
// supported layout prop, so forward the same per-document text options.
|
||||
const textOptions = { hyphenationCallback };
|
||||
return <RendererLink {...textOptions} {...props} />;
|
||||
const tagOf = (element: HTMLElement) => element.rawTagName?.toLowerCase() ?? "";
|
||||
|
||||
function matchingStyles(element: HTMLElement, tag: string, stylesheet: Record<string, Style>): Style[] {
|
||||
const styles: Style[] = [];
|
||||
if (TAG_STYLES[tag]) styles.push(TAG_STYLES[tag]);
|
||||
for (const [selector, style] of Object.entries(stylesheet)) {
|
||||
const matches = selector.startsWith(".") ? element.classList.contains(selector.slice(1)) : selector === tag;
|
||||
if (matches) styles.push(style);
|
||||
}
|
||||
const inline = parseInlineStyle(element.getAttribute("style"));
|
||||
if (inline) styles.push(inline);
|
||||
return styles;
|
||||
}
|
||||
|
||||
/** react-pdf-html creates native Text buckets outside our Text component. */
|
||||
function applyDocumentHyphenation(
|
||||
node: ReactNode,
|
||||
hyphenationCallback: NonNullable<TextProps["hyphenationCallback"]>,
|
||||
): ReactNode {
|
||||
if (Array.isArray(node)) return node.map((child) => applyDocumentHyphenation(child, hyphenationCallback));
|
||||
if (!isValidElement(node)) return node;
|
||||
const element = node as ReactElement<{
|
||||
children?: ReactNode;
|
||||
hyphenationCallback?: TextProps["hyphenationCallback"];
|
||||
}>;
|
||||
const props = element.type === RendererText || element.type === RendererLink ? { hyphenationCallback } : {};
|
||||
return cloneElement(element, props, applyDocumentHyphenation(element.props.children, hyphenationCallback));
|
||||
type Converted = HtmlElement | string;
|
||||
|
||||
/** Gives every element in the tree its styles and `indexOfType`, before anything asks whether it's a block. */
|
||||
function annotate(element: HtmlElement, stylesheet: Record<string, Style>) {
|
||||
element.styles = matchingStyles(element, tagOf(element), stylesheet);
|
||||
const counters = new Map<string, number>();
|
||||
for (const child of element.childNodes) {
|
||||
if (child.nodeType !== NodeType.ELEMENT_NODE) continue;
|
||||
const childElement = child as HtmlElement;
|
||||
const tag = tagOf(childElement);
|
||||
childElement.indexOfType = counters.get(tag) ?? 0;
|
||||
counters.set(tag, childElement.indexOfType + 1);
|
||||
annotate(childElement, stylesheet);
|
||||
}
|
||||
}
|
||||
|
||||
export function Html(props: HtmlProps) {
|
||||
const { hyphenationCallback } = useRender();
|
||||
return applyDocumentHyphenation(renderHtml(props.children, props), hyphenationCallback);
|
||||
const toConverted = (node: Node): Converted | null => {
|
||||
if (node.nodeType === NodeType.TEXT_NODE) return node.text;
|
||||
return node.nodeType === NodeType.ELEMENT_NODE ? (node as HtmlElement) : null;
|
||||
};
|
||||
|
||||
const isBlockStyle = (style: Style) => style.display === "flex" || (style.display as string) === "block";
|
||||
|
||||
/** Whether an element lays out as a block (a View) rather than inside a line of text. */
|
||||
function isBlock(node: Converted): boolean {
|
||||
if (typeof node === "string") return false;
|
||||
const tag = tagOf(node);
|
||||
if (tag !== "a" && !INLINE_TAGS.has(tag)) return true;
|
||||
if (node.styles.some(isBlockStyle)) return true;
|
||||
return node.childNodes.some((child) => child.nodeType === NodeType.ELEMENT_NODE && isBlock(child as HtmlElement));
|
||||
}
|
||||
|
||||
type RenderOptions = { renderers: Record<string, HtmlRenderer>; collapse: boolean };
|
||||
|
||||
const collapseWhitespace = (text: string) => text.replace(/[\t\n\f\r ]+/g, " ");
|
||||
|
||||
/**
|
||||
* Children in runs: consecutive inline content shares one Text (one paragraph of lines), blocks stand alone.
|
||||
* Document whitespace collapses as in a browser; authored Unicode spaces are kept.
|
||||
*/
|
||||
function renderChildren(parent: HtmlElement | undefined, nodes: Node[], options: RenderOptions): ReactNode {
|
||||
const converted = nodes.map(toConverted).filter((node): node is Converted => node !== null);
|
||||
const runs: { block: boolean; items: Converted[] }[] = [];
|
||||
let lastBlock: boolean | undefined;
|
||||
|
||||
converted.forEach((node, index) => {
|
||||
let item = node;
|
||||
if (typeof item === "string" && options.collapse) {
|
||||
if (lastBlock !== false) item = item.replace(/^[\t\n\f\r ]+/, "");
|
||||
const next = converted[index + 1];
|
||||
if (next && isBlock(next)) item = item.replace(/[\t\n\f\r ]+$/, "");
|
||||
item = collapseWhitespace(item);
|
||||
}
|
||||
if (item === "") return;
|
||||
const block = isBlock(item);
|
||||
if (block !== lastBlock || runs.length === 0) runs.push({ block, items: [] });
|
||||
runs.at(-1)?.items.push(item);
|
||||
lastBlock = block;
|
||||
});
|
||||
|
||||
const parentIsText = parent !== undefined && tagOf(parent) !== "a" && !isBlock(parent);
|
||||
const rendered = runs.map((run, runIndex) => {
|
||||
const items = run.items.map((item, index) => renderNode(item, options, index));
|
||||
const [first] = run.items;
|
||||
const loneAnchor = run.items.length === 1 && typeof first === "object" && tagOf(first) === "a";
|
||||
const wrap = !run.block && !parentIsText && !loneAnchor && (items.length > 1 || typeof run.items[0] === "string");
|
||||
return wrap
|
||||
? createElement(Text, { key: runIndex }, ...items)
|
||||
: createElement(Fragment, { key: runIndex }, ...items);
|
||||
});
|
||||
return rendered.length === 1 ? rendered[0] : rendered;
|
||||
}
|
||||
|
||||
function renderNode(node: Converted, options: RenderOptions, key: number): ReactNode {
|
||||
if (typeof node === "string") return node;
|
||||
const tag = tagOf(node);
|
||||
const preserve = tag === "pre" || node.getAttribute("data-resume-whitespace") === "preserve";
|
||||
const children = renderChildren(node, node.childNodes, preserve ? { ...options, collapse: false } : options);
|
||||
const renderer = options.renderers[tag];
|
||||
if (renderer) return createElement(Fragment, { key }, renderer({ element: node, style: node.styles, children }));
|
||||
return createElement(isBlock(node) ? View : Text, { key, style: node.styles }, children);
|
||||
}
|
||||
|
||||
/** HTML from the rich-text editor, drawn with PDF primitives. Same contract as react-pdf-html's `Html`. */
|
||||
export function Html({ children, renderers = {}, stylesheet = {}, style, ...flowProps }: HtmlProps) {
|
||||
const root = parse(children, { comment: false }) as unknown as HtmlElement;
|
||||
annotate(root, stylesheet);
|
||||
return (
|
||||
<View {...flowProps} style={style}>
|
||||
{renderChildren(undefined, root.childNodes, { renderers, collapse: true })}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "*.wasm?url" {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
Generated
+84
@@ -1273,6 +1273,12 @@ importers:
|
||||
|
||||
packages/pdf:
|
||||
dependencies:
|
||||
'@formepdf/core':
|
||||
specifier: 0.25.0
|
||||
version: 0.25.0(react@19.3.0)
|
||||
'@formepdf/react':
|
||||
specifier: 0.25.0
|
||||
version: 0.25.0(react@19.3.0)
|
||||
'@react-pdf/hyphenate':
|
||||
specifier: 0.1.0
|
||||
version: 0.1.0
|
||||
@@ -1297,6 +1303,9 @@ importers:
|
||||
fast-png:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
node-html-parser:
|
||||
specifier: ^9.0.4
|
||||
version: 9.0.4
|
||||
@@ -1309,6 +1318,9 @@ importers:
|
||||
react-pdf-html:
|
||||
specifier: ^2.1.5
|
||||
version: 2.1.5(patch_hash=e8456fc885241f35e3b642ad6122015b88f855d717663793f5b3293c19afac9b)(@react-pdf/renderer@4.9.0(react@19.3.0))(react@19.3.0)
|
||||
react-reconciler:
|
||||
specifier: 0.34.0
|
||||
version: 0.34.0(react@19.3.0)
|
||||
devDependencies:
|
||||
'@napi-rs/canvas':
|
||||
specifier: 1.0.9
|
||||
@@ -1322,6 +1334,9 @@ importers:
|
||||
'@types/react':
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0
|
||||
'@types/react-reconciler':
|
||||
specifier: ^0.33.0
|
||||
version: 0.33.0(@types/react@19.3.0)
|
||||
'@typescript/native-preview':
|
||||
specifier: 7.0.0-dev.20260707.2
|
||||
version: 7.0.0-dev.20260707.2
|
||||
@@ -1498,6 +1513,9 @@ importers:
|
||||
'@lingui/format-po':
|
||||
specifier: ^6.8.0
|
||||
version: 6.8.0
|
||||
'@phosphor-icons/react':
|
||||
specifier: ^2.1.10
|
||||
version: 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||
'@reactive-resume/config':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/config
|
||||
@@ -1531,6 +1549,12 @@ importers:
|
||||
pg:
|
||||
specifier: ^8.23.0
|
||||
version: 8.23.0
|
||||
react:
|
||||
specifier: 19.3.0
|
||||
version: 19.3.0
|
||||
react-dom:
|
||||
specifier: 19.3.0
|
||||
version: 19.3.0(react@19.3.0)
|
||||
tsx:
|
||||
specifier: ^4.23.15
|
||||
version: 4.23.15
|
||||
@@ -2794,6 +2818,19 @@ packages:
|
||||
'@fontsource-variable/newsreader@5.3.0':
|
||||
resolution: {integrity: sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw==}
|
||||
|
||||
'@formepdf/core@0.25.0':
|
||||
resolution: {integrity: sha512-elxPOq5BT9X9vCBH9nx1Q9sYiizvRKu70cGRXC4nDOJGt482QcUye7R2OUHq5lCqoNTl3HElGhNcW0t/1Eh+PA==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@formepdf/react@0.25.0':
|
||||
resolution: {integrity: sha512-ZcM/807fuuxyk1vTUJu9rsj9vpM1IjSfV6iTh2kHxHTi+Md9L/rFmiCh/YTsUbYJ2c1Dmz33z9f66C8EzFCHzw==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@formepdf/shared@0.25.0':
|
||||
resolution: {integrity: sha512-0S3Vs3m6nev/XsDRR9LGmeZSXqFxCtHVnjVBTlyPhPQeeAPIE0S7DrvdneQqrhNMx2D6+58irR5eUOoyY3jsgg==}
|
||||
|
||||
'@hexagon/base64@1.1.28':
|
||||
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||
|
||||
@@ -4916,6 +4953,11 @@ packages:
|
||||
peerDependencies:
|
||||
'@types/react': ^19.3.0
|
||||
|
||||
'@types/react-reconciler@0.33.0':
|
||||
resolution: {integrity: sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
|
||||
'@types/react@19.3.0':
|
||||
resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
|
||||
|
||||
@@ -6261,6 +6303,10 @@ packages:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -7713,6 +7759,9 @@ packages:
|
||||
parse-svg-path@0.1.2:
|
||||
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
|
||||
|
||||
parse5@7.3.0:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
parseley@0.12.1:
|
||||
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
|
||||
|
||||
@@ -8060,6 +8109,12 @@ packages:
|
||||
'@react-pdf/renderer': '>=3.4.4'
|
||||
react: '>=16'
|
||||
|
||||
react-reconciler@0.34.0:
|
||||
resolution: {integrity: sha512-eVibvB1fc9j5Qf1KRkiM3GzRPBdjg2P6G6QV2f3P6Eb3OGMxRBhdg2rrUwjbV5JrQaJj8nOa8uajmmJA5tJWjQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
peerDependencies:
|
||||
react: ^19.3.0
|
||||
|
||||
react-remove-scroll-bar@2.3.8:
|
||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -10436,6 +10491,20 @@ snapshots:
|
||||
|
||||
'@fontsource-variable/newsreader@5.3.0': {}
|
||||
|
||||
'@formepdf/core@0.25.0(react@19.3.0)':
|
||||
dependencies:
|
||||
'@formepdf/react': 0.25.0(react@19.3.0)
|
||||
react: 19.3.0
|
||||
|
||||
'@formepdf/react@0.25.0(react@19.3.0)':
|
||||
dependencies:
|
||||
'@formepdf/shared': 0.25.0
|
||||
react: 19.3.0
|
||||
|
||||
'@formepdf/shared@0.25.0':
|
||||
dependencies:
|
||||
parse5: 7.3.0
|
||||
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
'@hono/node-server@2.1.1(hono@4.13.9)':
|
||||
@@ -12544,6 +12613,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
'@types/react-reconciler@0.33.0(@types/react@19.3.0)':
|
||||
dependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
'@types/react@19.3.0':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
@@ -13518,6 +13591,8 @@ snapshots:
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
entities@8.1.0: {}
|
||||
@@ -15266,6 +15341,10 @@ snapshots:
|
||||
|
||||
parse-svg-path@0.1.2: {}
|
||||
|
||||
parse5@7.3.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
parseley@0.12.1:
|
||||
dependencies:
|
||||
leac: 0.6.0
|
||||
@@ -15648,6 +15727,11 @@ snapshots:
|
||||
node-html-parser: 6.1.13
|
||||
react: 19.3.0
|
||||
|
||||
react-reconciler@0.34.0(react@19.3.0):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
scheduler: 0.28.0
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.0):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* Writes `packages/pdf/src/forme/phosphor-icons.json`: every regular-weight Phosphor icon as the SVG markup inside
|
||||
* its 256×256 viewBox, keyed by its kebab-case name ("address-book"). The PDF engine draws icons from these strings.
|
||||
*/
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const defsDirectory = join(dirname(require.resolve("@phosphor-icons/react")), "defs");
|
||||
const target = fileURLToPath(new URL("../../packages/pdf/src/forme/phosphor-icons.json", import.meta.url));
|
||||
|
||||
// "AddressBookTabs" → "address-book-tabs", "XLogo" → "x-logo", "NumberCircleOne" → "number-circle-one".
|
||||
export const kebab = (name: string) =>
|
||||
name
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
|
||||
// Forme 0.25 misreads path numbers written back to back ("-21.11-.13-.06"), drawing some icons as blobs, so every
|
||||
// number and command gets its own space-separated token.
|
||||
const spacePathData = (markup: string) =>
|
||||
markup.replace(/ d="([^"]*)"/g, (_, data: string) => {
|
||||
const tokens = data.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) ?? [];
|
||||
return ` d="${tokens.join(" ")}"`;
|
||||
});
|
||||
|
||||
const files = (await readdir(defsDirectory)).filter((file) => file.endsWith(".es.js")).sort();
|
||||
const icons: Record<string, string> = {};
|
||||
for (const file of files) {
|
||||
const module = (await import(join(defsDirectory, file))) as { default: Map<string, unknown> };
|
||||
const regular = module.default.get("regular");
|
||||
if (!regular) continue;
|
||||
icons[kebab(file.replace(/\.es\.js$/, ""))] = spacePathData(
|
||||
renderToStaticMarkup(regular as Parameters<typeof renderToStaticMarkup>[0]),
|
||||
);
|
||||
}
|
||||
|
||||
await writeFile(target, `${JSON.stringify(icons)}\n`);
|
||||
console.log(`Wrote ${Object.keys(icons).length} icons to ${target}`);
|
||||
@@ -10,24 +10,28 @@
|
||||
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"pdf:translations": "tsx locales/generate.ts",
|
||||
"icons:build": "tsx icons/material-symbols.ts"
|
||||
"icons:build": "tsx icons/material-symbols.ts",
|
||||
"icons:pdf": "tsx icons/phosphor-pdf.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reactive-resume/schema": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lingui/format-po": "^6.8.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@reactive-resume/docx": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@reactive-resume/pdf": "workspace:*",
|
||||
"@reactive-resume/resume": "workspace:*",
|
||||
"@types/pg": "^8.23.1",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"jszip": "3.10.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"jszip": "3.10.2",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"pg": "^8.23.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"tsx": "^4.23.15",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user