mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-01 01:04:29 +10:00
feat(pdf): add opt-in German hyphenation
This commit is contained in:
@@ -46,6 +46,7 @@
|
||||
"@orpc/openapi": "^1.15.0",
|
||||
"@orpc/server": "^1.15.0",
|
||||
"@orpc/zod": "^1.15.0",
|
||||
"@react-pdf/hyphenate": "0.1.0",
|
||||
"@react-pdf/renderer": "^4.9.0",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
|
||||
@@ -1523,6 +1523,10 @@ msgstr "Current Password"
|
||||
msgid "Current stage"
|
||||
msgstr "Current stage"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
|
||||
msgid "Currently available for German resumes. Uses the language set in Page."
|
||||
msgstr "Currently available for German resumes. Uses the language set in Page."
|
||||
|
||||
#: src/components/input/color-picker.tsx
|
||||
msgid "Custom"
|
||||
msgstr "Custom"
|
||||
@@ -2645,6 +2649,10 @@ msgstr "https://gateway.example.com/v1"
|
||||
msgid "Hungarian"
|
||||
msgstr "Hungarian"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
|
||||
msgid "Hyphenation"
|
||||
msgstr "Hyphenation"
|
||||
|
||||
#: src/components/level/combobox.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx
|
||||
msgid "Icon"
|
||||
@@ -3529,6 +3537,7 @@ msgstr "Not sure what to write?"
|
||||
msgid "Note"
|
||||
msgstr "Note"
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
#: src/features/applications/components/application-form-sheet.tsx
|
||||
#: src/libs/resume/section.tsx
|
||||
msgid "Notes"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
Third-party notices for German PDF hyphenation
|
||||
=============================================
|
||||
|
||||
Reactive Resume includes the following third-party components for German
|
||||
PDF hyphenation. Their copyright and license notices follow.
|
||||
|
||||
@react-pdf/hyphenate 0.1.0
|
||||
-------------------------
|
||||
Source: https://github.com/diegomura/react-pdf/tree/master/packages/hyphenate
|
||||
License source: https://github.com/diegomura/react-pdf/blob/master/LICENSE
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-present Diego Muracciole <diegomuracciole@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
German hyphenation patterns (de-1996), version 2018-03-31
|
||||
------------------------------------------------------
|
||||
Reformed German orthography (2006).
|
||||
Authors: Deutschsprachige Trennmustermannschaft <trennmuster@dante.de>
|
||||
Distributed by hyphen 1.6.6 and used by @react-pdf/hyphenate.
|
||||
Source: https://github.com/ytiurin/hyphen/blob/d008cbe2b4da162e7ec71dbb624d527aa3028eca/patterns/de-1996.js
|
||||
Pattern source: http://repo.or.cz/w/wortliste.git?a=commit;h=8b9a428c271e064f0047a364c532308f0fd4051f
|
||||
|
||||
Copyright (C) 2013-2018
|
||||
Stephan Hennig, Werner Lemberg, Günter Milde,
|
||||
Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke,
|
||||
Tobias Wendorf
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the “Software”), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
hyphen 1.6.6
|
||||
------------
|
||||
Source: https://github.com/ytiurin/hyphen/tree/d008cbe2b4da162e7ec71dbb624d527aa3028eca
|
||||
|
||||
ISC License (ISC)
|
||||
|
||||
Copyright (c) 2021, Yevhen Tiurin <yevhentiurin@gmail.com>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ReactNode } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const state = vi.hoisted(() => ({ data: {} as ResumeData, update: vi.fn() }));
|
||||
|
||||
type SectionBaseProps = { children: ReactNode };
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("@/components/typography/combobox", () => ({
|
||||
FontFamilyCombobox: () => null,
|
||||
FontWeightCombobox: () => null,
|
||||
}));
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useResume: () => ({ data: state.data }),
|
||||
useUpdateResumeData: () => state.update,
|
||||
}));
|
||||
|
||||
const { TypographySectionBuilder } = await import("./typography");
|
||||
|
||||
function Typography() {
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<TypographySectionBuilder />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.loadAndActivate({ locale: "en-US", messages: {} });
|
||||
state.data = structuredClone(defaultResumeData);
|
||||
state.data.metadata.page.locale = "de-DE";
|
||||
state.update.mockReset().mockImplementation((recipe: (draft: ResumeData) => void) => {
|
||||
const next = structuredClone(state.data);
|
||||
recipe(next);
|
||||
state.data = next;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Typography hyphenation", () => {
|
||||
it("keeps legacy missing preferences absent when another font setting changes", () => {
|
||||
render(<Typography />);
|
||||
fireEvent.change(screen.getByDisplayValue("10"), { target: { value: "12" } });
|
||||
expect(state.data.metadata.typography.body.fontSize).toBe(12);
|
||||
expect(state.data.metadata.typography).not.toHaveProperty("hyphenation");
|
||||
});
|
||||
|
||||
it.each([undefined, false])("keeps %s preferences off without saving on mount", (preference) => {
|
||||
state.data.metadata.typography.hyphenation = preference;
|
||||
render(<Typography />);
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: "Hyphenation" });
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(toggle).toHaveAccessibleDescription(
|
||||
"Currently available for German resumes. Uses the language set in Page.",
|
||||
);
|
||||
expect(state.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves opt-in and opt-out while preserving font settings and the resume language", () => {
|
||||
const before = structuredClone(state.data.metadata);
|
||||
const view = render(<Typography />);
|
||||
fireEvent.click(screen.getByText("Hyphenation"));
|
||||
expect(state.data.metadata.typography.hyphenation).toBe(true);
|
||||
expect(screen.getByRole("switch", { name: "Hyphenation" })).toBeChecked();
|
||||
|
||||
view.unmount();
|
||||
render(<Typography />);
|
||||
expect(screen.getByRole("switch", { name: "Hyphenation" })).toBeChecked();
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Hyphenation" }));
|
||||
expect(state.data.metadata.typography).toEqual({ ...before.typography, hyphenation: false });
|
||||
expect(state.data.metadata.page).toEqual(before.page);
|
||||
});
|
||||
|
||||
it("retains the saved preference when the resume language changes", () => {
|
||||
state.data.metadata.typography.hyphenation = true;
|
||||
const view = render(<Typography />);
|
||||
state.data = structuredClone(state.data);
|
||||
state.data.metadata.page.locale = "en-US";
|
||||
view.rerender(<Typography />);
|
||||
|
||||
expect(screen.getByRole("switch", { name: "Hyphenation" })).toBeChecked();
|
||||
expect(state.update).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByDisplayValue("10"), { target: { value: "12" } });
|
||||
expect(state.data.metadata.typography.hyphenation).toBe(true);
|
||||
expect(state.data.metadata.page.locale).toBe("en-US");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { typographySchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { FontFamilyCombobox, FontWeightCombobox } from "@/components/typography/combobox";
|
||||
import { getNextWeights } from "@/components/typography/get-next-weights";
|
||||
import { useResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
@@ -55,6 +56,7 @@ function TypographySectionForm() {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.typography.body = data.body;
|
||||
draft.metadata.typography.heading = data.heading;
|
||||
if (data.hyphenation !== undefined) draft.metadata.typography.hyphenation = data.hyphenation;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -77,6 +79,31 @@ function TypographySectionForm() {
|
||||
<TypographyGroupFields form={form} prefix="body" handleAutoSave={handleAutoSave} />
|
||||
<TypographyFieldGroup label={<Trans context="Headings or Titles (H1, H2, H3, H4, H5, H6)">Heading</Trans>} />
|
||||
<TypographyGroupFields form={form} prefix="heading" handleAutoSave={handleAutoSave} />
|
||||
<form.Field name="hyphenation">
|
||||
{(field) => (
|
||||
<FormItem className="col-span-full mt-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<FormControl
|
||||
render={
|
||||
<Switch
|
||||
checked={field.state.value === true}
|
||||
onCheckedChange={(checked) => {
|
||||
field.handleChange(checked);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormLabel>
|
||||
<Trans>Hyphenation</Trans>
|
||||
</FormLabel>
|
||||
</div>
|
||||
<FormDescription>
|
||||
<Trans>Currently available for German resumes. Uses the language set in Page.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3759,6 +3759,10 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
|
||||
"lineHeight"
|
||||
],
|
||||
"description": "The typography for the headings of the resume."
|
||||
},
|
||||
"hyphenation": {
|
||||
"description": "Enable automatic PDF hyphenation using the resume language. Currently supports German. Defaults to false.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -3466,6 +3466,10 @@
|
||||
"fontFamily"
|
||||
],
|
||||
"description": "The typography for the headings of the resume."
|
||||
},
|
||||
"hyphenation": {
|
||||
"description": "Enable automatic PDF hyphenation using the resume language. Currently supports German. Defaults to false.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -13799,6 +13803,10 @@
|
||||
"lineHeight"
|
||||
],
|
||||
"description": "The typography for the headings of the resume."
|
||||
},
|
||||
"hyphenation": {
|
||||
"type": "boolean",
|
||||
"description": "Enable automatic PDF hyphenation using the resume language. Currently supports German. Defaults to false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -17332,6 +17340,10 @@
|
||||
"lineHeight"
|
||||
],
|
||||
"description": "The typography for the headings of the resume."
|
||||
},
|
||||
"hyphenation": {
|
||||
"type": "boolean",
|
||||
"description": "Enable automatic PDF hyphenation using the resume language. Currently supports German. Defaults to false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -21021,6 +21033,10 @@
|
||||
"lineHeight"
|
||||
],
|
||||
"description": "The typography for the headings of the resume."
|
||||
},
|
||||
"hyphenation": {
|
||||
"type": "boolean",
|
||||
"description": "Enable automatic PDF hyphenation using the resume language. Currently supports German. Defaults to false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -24681,6 +24697,10 @@
|
||||
"lineHeight"
|
||||
],
|
||||
"description": "The typography for the headings of the resume."
|
||||
},
|
||||
"hyphenation": {
|
||||
"type": "boolean",
|
||||
"description": "Enable automatic PDF hyphenation using the resume language. Currently supports German. Defaults to false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -28629,6 +28649,10 @@
|
||||
"lineHeight"
|
||||
],
|
||||
"description": "The typography for the headings of the resume."
|
||||
},
|
||||
"hyphenation": {
|
||||
"type": "boolean",
|
||||
"description": "Enable automatic PDF hyphenation using the resume language. Currently supports German. Defaults to false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@better-auth/passkey",
|
||||
"@bramus/specificity",
|
||||
"@orpc/experimental-ratelimit",
|
||||
"@react-pdf/hyphenate",
|
||||
"@sindresorhus/slugify",
|
||||
"@t3-oss/env-core",
|
||||
"@uiw/color-convert",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"knip": "^6.34.0",
|
||||
"lefthook": "^2.1.12",
|
||||
"markdownlint-cli2": "0.23.2",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"pg": "^8.23.0",
|
||||
"turbo": "^2.10.12",
|
||||
"typescript": "^7.0.2",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/hyphenate": "0.1.0",
|
||||
"@react-pdf/renderer": "^4.9.0",
|
||||
"@reactive-resume/fonts": "workspace:*",
|
||||
"@reactive-resume/resume": "workspace:*",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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 { isRTL } from "@reactive-resume/utils/locale";
|
||||
import { isCJKLocale, isRTL } from "@reactive-resume/utils/locale";
|
||||
import { resumeContentContainsCJK } from "./hooks/use-register-fonts";
|
||||
import { createHyphenationCallback } from "./hyphenation";
|
||||
|
||||
export type ResumeRenderOptions = {
|
||||
includeCoverLetterHeader?: boolean;
|
||||
@@ -12,6 +15,7 @@ type RenderContextValue = ResumeData & {
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
renderOptions: ResumeRenderOptions;
|
||||
rtl: boolean;
|
||||
hyphenationCallback: ReturnType<typeof createHyphenationCallback>;
|
||||
};
|
||||
|
||||
const RenderContext = createContext<RenderContextValue | null>(null);
|
||||
@@ -31,9 +35,18 @@ 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 }),
|
||||
[data, resolveSectionTitle, renderOptions, rtl],
|
||||
() => ({ ...data, resolveSectionTitle, renderOptions, rtl, hyphenationCallback }),
|
||||
[data, resolveSectionTitle, renderOptions, rtl, hyphenationCallback],
|
||||
);
|
||||
|
||||
return <RenderContext.Provider value={contextValue}>{children}</RenderContext.Provider>;
|
||||
|
||||
@@ -43,6 +43,15 @@ describe("registerFonts", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("retains the hyphenation preference when adding fallback font stacks", async () => {
|
||||
vi.spyOn(Font, "register").mockImplementation(() => {});
|
||||
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
|
||||
const { registerFonts } = await import("./use-register-fonts");
|
||||
const configured = { ...typography, hyphenation: true };
|
||||
expect(registerFonts(configured, "de-DE", false, new Set(["emoji"])).hyphenation).toBe(true);
|
||||
expect(registerFonts(configured, "zh-CN").hyphenation).toBe(true);
|
||||
});
|
||||
|
||||
it("registers CJK PDF fallbacks for normal and italic text styles", async () => {
|
||||
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
|
||||
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
|
||||
|
||||
@@ -315,6 +315,7 @@ export const registerFonts = (
|
||||
headingFallbacks.length > 0 ? [headingFontFamily, ...headingFallbacks] : headingFontFamily;
|
||||
|
||||
return {
|
||||
...pdfTypography,
|
||||
body: { ...pdfTypography.body, fontFamily: bodyStack },
|
||||
heading: { ...pdfTypography.heading, fontFamily: headingStack },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
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(["تطوير"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { ComponentProps, ReactNode } from "react";
|
||||
import type { StyleInput } from "./styles";
|
||||
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
|
||||
import { Children, isValidElement } from "react";
|
||||
import { Image, Link as PdfLink, Text as PdfText, View } from "#react-pdf-renderer";
|
||||
import { Image, View } from "#react-pdf-renderer";
|
||||
import { useRender } from "../../context";
|
||||
import { resolvedPdfFlowProps, resolvedPdfTextProps } from "../../semantic/adapter";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useSemanticNodeVisible,
|
||||
} from "../../semantic/context";
|
||||
import { semanticNodeKeys } from "../../semantic/node-keys";
|
||||
import { Link as PdfLink, Text as PdfText } from "../../text";
|
||||
import { useSectionStyleRule, useTemplateIconSlot, useTemplatePageNodeKey, useTemplateStyle } from "./context";
|
||||
import { resolveIconSize } from "./icon-size";
|
||||
import { safeTextStyle } from "./safe-text-style";
|
||||
|
||||
@@ -9,6 +9,15 @@ type PdfElement = ReactElement<{ children?: unknown; element?: { tag: string } }
|
||||
const getPdfElementProps = (element: unknown) => (element as PdfElement).props;
|
||||
|
||||
describe("normalizeRichTextHtml", () => {
|
||||
it("decodes opted-in soft hyphens in text without changing links or escaped literals", () => {
|
||||
const html =
|
||||
'<p title="­">Soft­ware ­ ­ &shy; <a href="https://example.com/­">link</a></p>';
|
||||
expect(normalizeRichTextHtml(html)).toBe(html);
|
||||
expect(normalizeRichTextHtml(html, { softHyphens: true })).toBe(
|
||||
'<p title="­">Soft\u00ADware \u00AD \u00AD &shy; <a href="https://example.com/­">link</a></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps loose inline content in a <p>", () => {
|
||||
expect(normalizeRichTextHtml("hello world")).toBe("<p>hello world</p>");
|
||||
});
|
||||
|
||||
@@ -148,18 +148,27 @@ export const convertPseudoBulletParagraphs = (html: string): string =>
|
||||
return converted ?? full;
|
||||
});
|
||||
|
||||
const decodeSoftHyphens = (node: Node): void => {
|
||||
if (node.nodeType === NodeType.TEXT_NODE) {
|
||||
node.rawText = node.rawText.replace(/&(?:shy|#0*173|#[xX]0*[aA][dD]);/g, "\u00AD");
|
||||
}
|
||||
for (const child of node.childNodes) decodeSoftHyphens(child);
|
||||
};
|
||||
|
||||
type NormalizeRichTextHtmlOptions = {
|
||||
direction?: "ltr" | "rtl";
|
||||
softHyphens?: boolean;
|
||||
};
|
||||
|
||||
export const normalizeRichTextHtml = (
|
||||
html: string,
|
||||
{ direction = "ltr" }: NormalizeRichTextHtmlOptions = {},
|
||||
{ direction = "ltr", softHyphens = false }: NormalizeRichTextHtmlOptions = {},
|
||||
): string => {
|
||||
const root = parse(html.trim(), { comment: false });
|
||||
const normalized: string[] = [];
|
||||
let inlineNodes: string[] = [];
|
||||
|
||||
if (softHyphens) decodeSoftHyphens(root);
|
||||
normalizeBoldBoundaryWhitespace(root);
|
||||
normalizeMarkElements(root);
|
||||
unwrapSingleParagraphListItems(root);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { cloneElement, isValidElement } from "react";
|
||||
import { Html } from "react-pdf-html";
|
||||
import { Link as PdfLink, Text as PdfText, View } from "#react-pdf-renderer";
|
||||
import { View } from "#react-pdf-renderer";
|
||||
import { useRender } from "../../context";
|
||||
import { resolvedPdfFlowProps, resolvedPdfTextProps } from "../../semantic/adapter";
|
||||
import {
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
} from "../../semantic/context";
|
||||
import { semanticNodeKeys } from "../../semantic/node-keys";
|
||||
import { getRichTextSemanticNodeKey } from "../../semantic/rich-text-keys";
|
||||
import { Html, Link as PdfLink, Text as PdfText } from "../../text";
|
||||
import { useSectionStyleRule, useTemplateStyle } from "./context";
|
||||
import {
|
||||
normalizeRichTextHtml,
|
||||
@@ -74,7 +74,7 @@ const applyRtlDirectionRecursively = (node: ReactNode): ReactNode => {
|
||||
};
|
||||
|
||||
export const RichText = ({ children, semanticField }: RichTextProps) => {
|
||||
const { metadata, rtl } = useRender();
|
||||
const { metadata, rtl, hyphenationCallback } = useRender();
|
||||
const parentNodeKey = useSemanticNodeKey();
|
||||
const fieldNodeKey =
|
||||
parentNodeKey && semanticField ? semanticNodeKeys.field(parentNodeKey, semanticField) : undefined;
|
||||
@@ -108,7 +108,10 @@ export const RichText = ({ children, semanticField }: RichTextProps) => {
|
||||
);
|
||||
const proseSpacing = createRichTextProseSpacing(bodyLineHeight);
|
||||
|
||||
const normalizedHtml = normalizeRichTextHtml(children, { direction: rtl ? "rtl" : "ltr" });
|
||||
const normalizedHtml = normalizeRichTextHtml(children, {
|
||||
direction: rtl ? "rtl" : "ltr",
|
||||
softHyphens: metadata.typography.hyphenation === true && /^de(?:-|$)/i.test(metadata.page.locale),
|
||||
});
|
||||
const html = richTextNodeKey
|
||||
? projectNormalizedRichTextHtml(normalizedHtml, richTextNodeKey, renderedChildKeysFor)
|
||||
: normalizedHtml;
|
||||
@@ -221,7 +224,7 @@ export const RichText = ({ children, semanticField }: RichTextProps) => {
|
||||
...props,
|
||||
style: props.style,
|
||||
semanticStyle: resolved.style,
|
||||
textProps: resolvedPdfTextProps(resolved),
|
||||
textProps: { ...resolvedPdfTextProps(resolved), hyphenationCallback },
|
||||
rtl,
|
||||
...(rtlTextWrapStyle ? { rtlTextWrapStyle } : {}),
|
||||
...(rtl ? { applyRtlDirection: applyRtlDirectionRecursively } : {}),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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";
|
||||
|
||||
type TextProps = ComponentProps<typeof RendererText>;
|
||||
type LinkProps = ComponentProps<typeof RendererLink>;
|
||||
type HtmlProps = ComponentProps<typeof RendererHtml>;
|
||||
|
||||
export function Text(props: TextProps) {
|
||||
const { hyphenationCallback } = useRender();
|
||||
return <RendererText hyphenationCallback={hyphenationCallback} {...props} />;
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
|
||||
export function Html(props: HtmlProps) {
|
||||
const { hyphenationCallback } = useRender();
|
||||
return applyDocumentHyphenation(renderHtml(props.children, props), hyphenationCallback);
|
||||
}
|
||||
@@ -501,6 +501,12 @@ const designSchema = z.object({
|
||||
export const typographySchema = z.object({
|
||||
body: typographyItemSchema.describe("The typography for the body of the resume."),
|
||||
heading: typographyItemSchema.describe("The typography for the headings of the resume."),
|
||||
hyphenation: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Enable automatic PDF hyphenation using the resume language. Currently supports German. Defaults to false.",
|
||||
),
|
||||
});
|
||||
|
||||
const styleSlotSchema = z.enum([
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { typographySchema } from "./data";
|
||||
import { sampleResumeData } from "./sample";
|
||||
|
||||
describe("hyphenation preference", () => {
|
||||
it.each([true, false])("preserves the explicit %s preference", (hyphenation) => {
|
||||
const parsed = typographySchema.parse({ ...sampleResumeData.metadata.typography, hyphenation });
|
||||
expect(parsed.hyphenation).toBe(hyphenation);
|
||||
});
|
||||
|
||||
it("keeps older typography data unchanged with hyphenation disabled by default", () => {
|
||||
const parsed = typographySchema.parse(sampleResumeData.metadata.typography);
|
||||
expect(parsed.hyphenation ?? false).toBe(false);
|
||||
expect(parsed).toEqual(sampleResumeData.metadata.typography);
|
||||
});
|
||||
|
||||
it("rejects a non-boolean preference", () => {
|
||||
expect(typographySchema.safeParse({ ...sampleResumeData.metadata.typography, hyphenation: "true" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
Generated
+9
@@ -69,6 +69,9 @@ importers:
|
||||
markdownlint-cli2:
|
||||
specifier: 0.23.2
|
||||
version: 0.23.2(supports-color@7.2.0)
|
||||
pdfjs-dist:
|
||||
specifier: 6.3.289
|
||||
version: 6.3.289
|
||||
pg:
|
||||
specifier: ^8.23.0
|
||||
version: 8.23.0
|
||||
@@ -168,6 +171,9 @@ importers:
|
||||
'@orpc/zod':
|
||||
specifier: ^1.15.0
|
||||
version: 1.15.0(@orpc/contract@1.15.0)(@orpc/server@1.15.0(ws@8.21.3))(ws@8.21.3)(zod@4.5.4)
|
||||
'@react-pdf/hyphenate':
|
||||
specifier: 0.1.0
|
||||
version: 0.1.0
|
||||
'@react-pdf/renderer':
|
||||
specifier: ^4.9.0
|
||||
version: 4.9.0(react@19.2.8)
|
||||
@@ -1079,6 +1085,9 @@ importers:
|
||||
|
||||
packages/pdf:
|
||||
dependencies:
|
||||
'@react-pdf/hyphenate':
|
||||
specifier: 0.1.0
|
||||
version: 0.1.0
|
||||
'@react-pdf/renderer':
|
||||
specifier: ^4.9.0
|
||||
version: 4.9.0(react@19.2.8)
|
||||
|
||||
@@ -621,6 +621,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
|
||||
| `metadata.typography.heading.fontWeights[]` | `string` | — | enum: ["100","200","300","400","500","600","700","800","900"] | — |
|
||||
| `metadata.typography.heading.fontSize` | `number` | yes | minimum: 6; maximum: 24; default: 11 | The size of the font to use, defined in points (pt). |
|
||||
| `metadata.typography.heading.lineHeight` | `number` | yes | minimum: 0.5; maximum: 4; default: 1.5 | The line height of the font to use, defined as a multiplier of the font size (e.g. 1.5 for 1.5x). |
|
||||
| `metadata.typography.hyphenation` | `boolean` | no | — | Enable automatic PDF hyphenation using the resume language. Currently supports German. Defaults to false. |
|
||||
| `metadata.notes` | `string` | yes | — | Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string. |
|
||||
| `metadata.styleRules` | `array` | yes | — | Structured style rules that target semantic resume sections and slots for React PDF rendering. |
|
||||
| `metadata.styleRules[]` | `any` | — | — | — |
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { Pool } from "pg";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
const longWord = "Gewerbesteuerdurchführungsverordnung";
|
||||
|
||||
async function seedHyphenationResume(page: Page) {
|
||||
const resumeId = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1];
|
||||
if (!resumeId || !process.env.DATABASE_URL) throw new Error("Missing hyphenation fixture database or resume.");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
let slug: string | undefined;
|
||||
const patches = [
|
||||
{ path: ["picture", "hidden"], value: true },
|
||||
{
|
||||
path: ["basics"],
|
||||
value: {
|
||||
name: "Probe",
|
||||
headline: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
location: "",
|
||||
website: { url: "", label: "" },
|
||||
customFields: [],
|
||||
},
|
||||
},
|
||||
{ path: ["summary", "title"], value: "Text" },
|
||||
{ path: ["summary", "content"], value: `<p>${longWord}</p>` },
|
||||
{ path: ["metadata", "template"], value: "onyx" },
|
||||
{ path: ["metadata", "layout", "pages"], value: [{ fullWidth: true, main: ["summary"], sidebar: [] }] },
|
||||
{ path: ["metadata", "typography", "body", "fontFamily"], value: "Helvetica" },
|
||||
{ path: ["metadata", "typography", "heading", "fontFamily"], value: "Helvetica" },
|
||||
{
|
||||
path: ["metadata", "stylesheet"],
|
||||
value: {
|
||||
mode: "semantic",
|
||||
source: {
|
||||
languageVersion: 1,
|
||||
text: "@version 1; section { width: 100pt; } paragraph { font-size: 12pt; text-align: justify; }",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
try {
|
||||
for (const { path, value } of patches) {
|
||||
await pool.query('update "resume" set data = jsonb_set(data, $2::text[], $3::jsonb) where id = $1', [
|
||||
resumeId,
|
||||
path,
|
||||
JSON.stringify(value),
|
||||
]);
|
||||
}
|
||||
slug = (await pool.query<{ slug: string }>('select slug from "resume" where id = $1', [resumeId])).rows[0]?.slug;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
await page.reload();
|
||||
if (!slug) throw new Error("Missing hyphenation fixture slug.");
|
||||
return slug;
|
||||
}
|
||||
|
||||
async function downloadPdfText(page: Page, testInfo: TestInfo, name: string) {
|
||||
await openSidebarSection(page, "Export");
|
||||
await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click();
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download PDF", exact: true }).click();
|
||||
const download = await pending;
|
||||
const path = testInfo.outputPath(`${name}.pdf`);
|
||||
await download.saveAs(path);
|
||||
await page.keyboard.press("Escape");
|
||||
return readPdfText(new Uint8Array(await readFile(path)));
|
||||
}
|
||||
|
||||
async function readPdfText(data: Uint8Array) {
|
||||
const loading = getDocument({ data, useSystemFonts: true });
|
||||
try {
|
||||
const document = await loading.promise;
|
||||
const text: string[] = [];
|
||||
for (let number = 1; number <= document.numPages; number++) {
|
||||
const page = await document.getPage(number);
|
||||
text.push(...(await page.getTextContent()).items.flatMap((item) => ("str" in item ? [item.str] : [])));
|
||||
}
|
||||
return text.join(" ");
|
||||
} finally {
|
||||
await loading.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
test("keeps hyphenation opt-in saved across reloads and resume language changes", async ({
|
||||
authPage: page,
|
||||
account,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
const slug = await seedHyphenationResume(page);
|
||||
const notices = await page.request.get("/third-party-notices.txt");
|
||||
expect(notices.ok()).toBe(true);
|
||||
expect(await notices.text()).toBe(await readFile("apps/web/public/third-party-notices.txt", "utf8"));
|
||||
await openSidebarSection(page, "Typography");
|
||||
const toggle = page.getByRole("switch", { name: "Hyphenation", exact: true });
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await expect(toggle).toHaveAccessibleDescription(
|
||||
"Currently available for German resumes. Uses the language set in Page.",
|
||||
);
|
||||
|
||||
await openSidebarSection(page, "Page");
|
||||
await page.getByLabel("Language", { exact: true }).click();
|
||||
await page.getByRole("option", { name: /de-DE/ }).click();
|
||||
await expect(page.getByText("Saved", { exact: true })).toBeVisible();
|
||||
const disabledText = await downloadPdfText(page, testInfo, "hyphenation-default-off");
|
||||
expect(disabledText).not.toContain("-");
|
||||
expect(disabledText.replaceAll(/\s/g, "")).toContain(longWord);
|
||||
await openSidebarSection(page, "Typography");
|
||||
await toggle.check();
|
||||
await expect(page.getByText("Saved", { exact: true })).toBeVisible();
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Typography");
|
||||
await expect(toggle).toBeChecked();
|
||||
const enabledText = await downloadPdfText(page, testInfo, "hyphenation-german-enabled");
|
||||
expect(enabledText).toContain("-");
|
||||
expect(enabledText.replaceAll(/[\s-]/g, "")).toContain(longWord);
|
||||
const serverPdf = await page.request.get(
|
||||
`/api/resumes/${encodeURIComponent(account.username)}/${encodeURIComponent(slug)}/pdf`,
|
||||
);
|
||||
expect(serverPdf.ok()).toBe(true);
|
||||
expect(serverPdf.headers()["content-type"]).toContain("application/pdf");
|
||||
const serverBytes = await serverPdf.body();
|
||||
await writeFile(testInfo.outputPath("hyphenation-server-enabled.pdf"), serverBytes);
|
||||
expect(await readPdfText(new Uint8Array(serverBytes))).toBe(enabledText);
|
||||
|
||||
await openSidebarSection(page, "Page");
|
||||
await expect(page.getByLabel("Language", { exact: true })).toHaveText("German");
|
||||
await page.getByLabel("Language", { exact: true }).click();
|
||||
await page.getByRole("option", { name: /en-US/ }).click();
|
||||
await expect(page.getByText("Saved", { exact: true })).toBeVisible();
|
||||
const unsupportedText = await downloadPdfText(page, testInfo, "hyphenation-english-enabled");
|
||||
expect(unsupportedText).toBe(disabledText);
|
||||
await openSidebarSection(page, "Typography");
|
||||
await expect(toggle).toBeChecked();
|
||||
await page
|
||||
.locator('[data-slot="form-item"]')
|
||||
.filter({ has: toggle })
|
||||
.screenshot({
|
||||
path: testInfo.outputPath("hyphenation-preference.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await toggle.uncheck();
|
||||
await expect(page.getByText("Saved", { exact: true })).toBeVisible();
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Typography");
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await openSidebarSection(page, "Page");
|
||||
await page.getByLabel("Language", { exact: true }).click();
|
||||
await page.getByRole("option", { name: /de-DE/ }).click();
|
||||
await expect(page.getByText("Saved", { exact: true })).toBeVisible();
|
||||
expect(await downloadPdfText(page, testInfo, "hyphenation-german-disabled")).toBe(disabledText);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user