From 87b5d695a4cffb53f8c3ae55019101ef8539c720 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Thu, 1 Oct 2026 23:00:42 +0200 Subject: [PATCH] fix(pdf): wrap glalie contact text and center section heading icons Glalie's contact box let long emails and links run past its border: the converter dropped a link's lone text into a shared inline run, losing its box styles, so Forme sized it to its full width. A lone text now converts as its own block, and Glalie's contact text takes the rest of the row so Forme wraps it inside the box. Section heading icons sat at the top of the title's line box, above the text. The icon now moves down by half the leftover line height and never exceeds the line, so it stays level with the title in every template. --- packages/pdf/src/forme/to-forme.tsx | 7 +++++++ .../pdf/src/templates/glalie/GlaliePage.tsx | 7 +++++++ .../pdf/src/templates/shared/contact-item.tsx | 10 +++++----- .../pdf/src/templates/shared/primitives.tsx | 20 +++++++++++++++---- .../pdf/src/templates/shared/sections.tsx | 20 ++++++++++++------- .../src/templates/shared/template-base.tsx | 16 ++++++++++----- 6 files changed, 59 insertions(+), 21 deletions(-) diff --git a/packages/pdf/src/forme/to-forme.tsx b/packages/pdf/src/forme/to-forme.tsx index fddd3d3dd..687d91643 100644 --- a/packages/pdf/src/forme/to-forme.tsx +++ b/packages/pdf/src/forme/to-forme.tsx @@ -518,6 +518,13 @@ function convertNode(node: HostNode, parentContext: Context, key: number): React let inline: HostNode[] = []; const flush = () => { if (inline.length === 0) return; + // A lone text is a block of its own, as in react-pdf: it keeps its box styles (how it flexes in a row). + const [only] = inline; + if (inline.length === 1 && only && only.type !== "#text") { + children.push(convertNode(only, inner, children.length)); + inline = []; + return; + } const runs = convertChildren(inline, { ...inner, inText: true }); children.push(createElement(FormeText, { key: children.length, style: context.textDefaults }, ...runs)); inline = []; diff --git a/packages/pdf/src/templates/glalie/GlaliePage.tsx b/packages/pdf/src/templates/glalie/GlaliePage.tsx index ec8e30df1..bbeb6e5a9 100644 --- a/packages/pdf/src/templates/glalie/GlaliePage.tsx +++ b/packages/pdf/src/templates/glalie/GlaliePage.tsx @@ -30,6 +30,7 @@ type GlalieStyles = Omit & { headerName: Style; contactList: Style; contactItem: Style; + contactText: Style; }; type GlalieTemplate = { @@ -122,6 +123,7 @@ const Header = ({ styles }: GlalieHeaderProps) => ( name: styles.headerName, contactList: styles.contactList, contactItem: styles.contactItem, + contactText: styles.contactText, }} contactListOutsideTitle /> @@ -221,6 +223,11 @@ const useGlalieTemplate = (): GlalieTemplate => { alignItems: "center", columnGap: metrics.gapX(1 / 6), }, + // The text takes the rest of the row, so a long email or link wraps inside the box instead of running out. + contactText: { + flexGrow: 1, + flexBasis: 0, + }, }); const accentFor = ({ colors }: TemplateStyleContext) => colors.primary; diff --git a/packages/pdf/src/templates/shared/contact-item.tsx b/packages/pdf/src/templates/shared/contact-item.tsx index 729a47380..6fecc42f0 100644 --- a/packages/pdf/src/templates/shared/contact-item.tsx +++ b/packages/pdf/src/templates/shared/contact-item.tsx @@ -32,7 +32,7 @@ const useContactNodeKeys = (name: string, id?: string, primitiveNodeKey?: string type WebsiteContactItemProps = { website: WebsiteDisplay; style?: ContactStyle; - textStyle?: ContactStyle; + textStyle?: ContactStyle | undefined; iconColor?: string; primitiveNodeKey?: string | undefined; }; @@ -40,7 +40,7 @@ type WebsiteContactItemProps = { type CustomFieldContactItemProps = { field: CustomField; style?: ContactStyle; - textStyle?: ContactStyle; + textStyle?: ContactStyle | undefined; iconColor?: string; primitiveNodeKey?: string | undefined; }; @@ -105,7 +105,7 @@ export const CustomFieldContactItem = ({ type EmailContactItemProps = { email: string; style?: ContactStyle; - textStyle?: ContactStyle; + textStyle?: ContactStyle | undefined; iconColor?: string; /** Override icon; defaults to "envelope". ditgar uses "at". */ iconName?: IconName; @@ -136,7 +136,7 @@ export const EmailContactItem = ({ type PhoneContactItemProps = { phone: string; style?: ContactStyle; - textStyle?: ContactStyle; + textStyle?: ContactStyle | undefined; iconColor?: string; primitiveNodeKey?: string | undefined; }; @@ -158,7 +158,7 @@ export const PhoneContactItem = ({ phone, style, textStyle, iconColor, primitive type LocationContactItemProps = { location: string; style?: ContactStyle; - textStyle?: ContactStyle; + textStyle?: ContactStyle | undefined; iconColor?: string; primitiveNodeKey?: string | undefined; }; diff --git a/packages/pdf/src/templates/shared/primitives.tsx b/packages/pdf/src/templates/shared/primitives.tsx index df8c08b4f..ce34d0fcb 100644 --- a/packages/pdf/src/templates/shared/primitives.tsx +++ b/packages/pdf/src/templates/shared/primitives.tsx @@ -526,8 +526,13 @@ export const SectionHeadingIcon = ({ style, size: sizeProp, nodeKey, + titleLineHeight, ...props -}: ComponentProps & { nodeKey?: string | undefined }) => { +}: ComponentProps & { + nodeKey?: string | undefined; + /** Height in points of a line of the title beside the icon, which the icon is centred on. */ + titleLineHeight?: number | undefined; +}) => { const data = useRender(); const { style: sectionIconStyle, ...sectionIconProps } = useTemplateIconSlot("sectionHeadingIcon"); const { style: fallbackIconStyle, ...fallbackIconProps } = useTemplateIconSlot("icon"); @@ -545,13 +550,20 @@ export const SectionHeadingIcon = ({ // Icon size follows heading fontSize so they scale together const headingFontSize = data.metadata.typography.heading.fontSize; - const resolvedSize = + let resolvedSize = resolveIconSize({ size: sizeProp, styles: [asStyleInput(iconStyle), asStyleInput(style)], }) ?? templateIconSize ?? headingFontSize; + // The row lines its children up at the top, and a line's text sits in the middle of its line height: the icon + // moves down by half what's left of the line, and never grows past the line, so it stays level with the title. + let lineStyle: Style | undefined; + if (typeof resolvedSize === "number" && titleLineHeight !== undefined) { + resolvedSize = Math.min(resolvedSize, titleLineHeight); + lineStyle = { marginTop: (titleLineHeight - resolvedSize) / 2 }; + } const resolved = useResolvedNode(nodeKey); const visible = useSemanticNodeVisible(nodeKey); if (!visible) return null; @@ -560,9 +572,9 @@ export const SectionHeadingIcon = ({ ); }; diff --git a/packages/pdf/src/templates/shared/sections.tsx b/packages/pdf/src/templates/shared/sections.tsx index 47df3b751..76dc4ef01 100644 --- a/packages/pdf/src/templates/shared/sections.tsx +++ b/packages/pdf/src/templates/shared/sections.tsx @@ -54,6 +54,7 @@ import { useTemplateStyle, } from "./context"; import { filterItems, hasVisibleItems, isSectionVisible, isVisibleSummary } from "./filtering"; +import { parseStyleFontSize } from "./icon-size"; import { LevelDisplay } from "./level-display"; import { getTemplateMetrics } from "./metrics"; import { @@ -339,6 +340,7 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio const resolved = useResolvedNode(sectionNodeKey); const visible = useSemanticNodeVisible(sectionNodeKey); const sectionStyle = useTemplateStyle("section"); + const headingStyle = useTemplateStyle("heading"); const sectionHeadingStyle = useTemplateStyle("sectionHeading"); const sectionHeadingContainerStyle = useTemplateStyle("sectionHeadingContainer"); const sectionTitle = getResumeSectionTitle(data, sectionId, title); @@ -352,6 +354,15 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio const sectionHeadingVisible = useSemanticNodeVisible(sectionHeadingNodeKey); const sectionIcon = getResumeSectionIcon(data, sectionId); const showIcon = Boolean(sectionIcon) && !data.metadata.page.hideSectionIcons; + const sectionHeadingTextStyle = getSectionHeadingTextStyle( + sectionHeadingStyle, + sectionHeadingTextOf(sectionHeadingResolved.style), + ); + // The title as `Heading` draws it; a line height in other units than a multiplier falls back to the typography's. + const { fontSize: titleFontSize, lineHeight: titleLineHeight } = mergeStyles(headingStyle, sectionHeadingTextStyle); + const titleLineBox = + (parseStyleFontSize(titleFontSize) ?? data.metadata.typography.heading.fontSize) * + (typeof titleLineHeight === "number" ? titleLineHeight : data.metadata.typography.heading.lineHeight); const { keepTogether, startOnNewPage } = getSectionBreaks(data, sectionId); // wrap={false} keeps the whole section on one page; break forces it onto a fresh page. // Only set the props when enabled so we never pass undefined (exactOptionalPropertyTypes). @@ -396,14 +407,9 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio - + {sectionTitle} diff --git a/packages/pdf/src/templates/shared/template-base.tsx b/packages/pdf/src/templates/shared/template-base.tsx index bbe9396b5..47d38ff0f 100644 --- a/packages/pdf/src/templates/shared/template-base.tsx +++ b/packages/pdf/src/templates/shared/template-base.tsx @@ -55,6 +55,7 @@ type TemplateHeaderStyles = { name: Style; contactList: Style; contactItem: Style; + contactText?: Style | undefined; }; export const TemplateHeader = ({ @@ -68,12 +69,17 @@ export const TemplateHeader = ({ const hasPicture = hasTemplatePicture(picture); const contactList = ( - - - - + + + + {basics.customFields.map((field) => ( - + ))} );