diff --git a/packages/pdf/src/semantic/item-header-row.test.tsx b/packages/pdf/src/semantic/item-header-row.test.tsx index 3404f2240..cabc95d8f 100644 --- a/packages/pdf/src/semantic/item-header-row.test.tsx +++ b/packages/pdf/src/semantic/item-header-row.test.tsx @@ -62,7 +62,7 @@ const buildFixture = (): ResumeData => { return data; }; -const renderTitleRowStyle = async (template: Template, stylesheet?: string) => { +const renderTitleRowStyles = async (template: Template, stylesheet?: string) => { const data = buildFixture(); const semanticRuntime = stylesheet ? resolveResumeRuntime({ data, template, mode: "semantic", source: { languageVersion: 1, text: stylesheet } }) @@ -76,9 +76,10 @@ const renderTitleRowStyle = async (template: Template, stylesheet?: string) => { const path = pathTo(document, (node) => nodeText(node).trim() === LONG_TITLE); if (!path) throw new Error("Missing certification title in the rendered document"); const row = path.at(-2); - if (!row) throw new Error("Missing the row wrapping the certification title"); + const title = path.at(-1); + if (!row || !title) throw new Error("Missing the row wrapping the certification title"); - return mergedStyle(row); + return { row: mergedStyle(row), title: mergedStyle(title) }; }; describe("item-header-row template part", () => { @@ -112,8 +113,19 @@ describe("item-header-row template part", () => { // The row ships with `flex-wrap: wrap`, which drops a long title's date onto its own line. This // is the whole point of exposing the part, so assert the override reaches the rendered row. it.each(["onyx", "ditgar", "meowth"] as const)("lets %s stylesheets turn off the row wrap", async (template) => { - expect(await renderTitleRowStyle(template)).toMatchObject({ flexWrap: "wrap" }); - expect(await renderTitleRowStyle(template, NOWRAP_STYLESHEET)).toMatchObject({ flexWrap: "nowrap" }); + expect((await renderTitleRowStyles(template)).row).toMatchObject({ flexWrap: "wrap" }); + expect((await renderTitleRowStyles(template, NOWRAP_STYLESHEET)).row).toMatchObject({ flexWrap: "nowrap" }); + }); + + // React PDF reuses the line layout a Text was first measured with, so a title Yoga shrinks after + // that measurement draws its glyphs over the date. Under `nowrap` the title takes a zero flex + // basis instead, which makes its first measurement its final width. + it("gives the title a zero flex basis only when the row stops wrapping", async () => { + expect((await renderTitleRowStyles("onyx")).title).not.toMatchObject({ flexBasis: 0 }); + expect((await renderTitleRowStyles("onyx", NOWRAP_STYLESHEET)).title).toMatchObject({ + flexBasis: 0, + flexGrow: 1, + }); }); it("reports no diagnostics for the selector", () => { diff --git a/packages/pdf/src/templates/shared/sections.test.ts b/packages/pdf/src/templates/shared/sections.test.ts index 9430c7cec..7a1a8ea01 100644 --- a/packages/pdf/src/templates/shared/sections.test.ts +++ b/packages/pdf/src/templates/shared/sections.test.ts @@ -17,9 +17,8 @@ describe("ExperienceSection", () => { describe("ItemTitle", () => { it("renders award titles without the bold style", () => { expect(source).toContain("const ItemTitle = ({ children, website, field, bold = true }: ItemTitleProps)"); - expect(source).toContain( - "const title = bold ? {children} : {children};", - ); + expect(source).toContain("const title = bold ? (\n\t\t"); + expect(source).toContain("\t) : (\n\t\t"); expect(source).toContain(''); }); }); diff --git a/packages/pdf/src/templates/shared/sections.tsx b/packages/pdf/src/templates/shared/sections.tsx index 1becf50af..472c1024f 100644 --- a/packages/pdf/src/templates/shared/sections.tsx +++ b/packages/pdf/src/templates/shared/sections.tsx @@ -75,7 +75,7 @@ import { createRtlStyleHelpers } from "./rtl"; import { getInlineItemWebsiteUrl, shouldRenderSeparateItemWebsite } from "./section-links"; import { hasSplitRowText } from "./split-row"; import { getSectionStyleRuleContext } from "./style-rules"; -import { composeStyles } from "./styles"; +import { composeStyles, mergeStyles } from "./styles"; type SectionItemsContextValue = { itemStyle: StyleInput; @@ -618,6 +618,18 @@ type ItemHeaderRowProps = { style: StyleInput; }; +/** + * React PDF lays a `Text` out once, at the width it is first measured at, and reuses those lines + * afterwards. `flex-wrap: nowrap` asks Yoga to shrink the title so the date fits beside it, and the + * cached lines keep the wider layout, so the title's glyphs run over the date. A zero flex basis + * makes the title's first measurement its final width, so it wraps inside its own box instead. + */ +const nowrapItemTitleStyle = { flexGrow: 1, flexBasis: 0 } satisfies Style; +const wrappingItemTitleStyle = {} satisfies Style; + +/** True while rendering inside an item header row a stylesheet turned into a single line. */ +const ItemHeaderRowNowrapContext = createContext(false); + /** * The title/date row inside a section item header, exposed to Semantic CSS as * `template-part[name="item-header-row"]`. @@ -626,11 +638,20 @@ type ItemHeaderRowProps = { * the trailing date onto its own line, and `item-header` selects the box around the row rather than * the row itself. The owning item-header key comes from the surrounding provider. */ -const ItemHeaderRow = ({ children, style }: ItemHeaderRowProps) => ( - - {children} - -); +const ItemHeaderRow = ({ children, style }: ItemHeaderRowProps) => { + const ownerNodeKey = useSemanticNodeKey(); + const resolved = useResolvedNode(semanticTemplatePartNodeKey(ownerNodeKey, ...ITEM_HEADER_ROW_PART_KEYS)); + // Same order the rendered row composes in, so a template that ships `nowrap` counts too. + const { flexWrap } = mergeStyles(style, resolved.style); + + return ( + + + {children} + + + ); +}; const SectionItemHeader = ({ children }: SectionItemHeaderProps) => { const itemNodeKey = useSemanticNodeKey(); @@ -687,12 +708,21 @@ const SectionItemHeader = ({ children }: SectionItemHeaderProps) => { const ItemTitle = ({ children, website, field, bold = true }: ItemTitleProps) => { const inlineWebsiteUrl = getInlineItemWebsiteUrl(website); - const title = bold ? {children} : {children}; + const style = use(ItemHeaderRowNowrapContext) ? nowrapItemTitleStyle : wrappingItemTitleStyle; + const title = bold ? ( + + {children} + + ) : ( + + {children} + + ); if (!inlineWebsiteUrl) return title; return ( - + {title} );