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.
This commit is contained in:
Amruth Pillai
2026-10-01 23:00:42 +02:00
parent 8ab54c8ad0
commit 87b5d695a4
6 changed files with 59 additions and 21 deletions
+7
View File
@@ -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 = [];
@@ -30,6 +30,7 @@ type GlalieStyles = Omit<TemplateStyleSlots, "page"> & {
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;
@@ -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;
};
@@ -526,8 +526,13 @@ export const SectionHeadingIcon = ({
style,
size: sizeProp,
nodeKey,
titleLineHeight,
...props
}: ComponentProps<typeof PhosphorIcon> & { nodeKey?: string | undefined }) => {
}: ComponentProps<typeof PhosphorIcon> & {
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 = ({
<PhosphorIcon
{...iconPropsWithoutDisplay}
{...props}
{...(resolvedSize === undefined ? {} : { size: resolvedSize })}
size={resolvedSize}
{...(resolved.style?.color === undefined ? {} : { color: resolved.style.color })}
style={composeStyles(asStyleInput(iconStyle), asStyleInput(style), resolved.style)}
style={composeStyles(lineStyle, asStyleInput(iconStyle), asStyleInput(style), resolved.style)}
/>
);
};
+13 -7
View File
@@ -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
<SectionHeadingIcon
nodeKey={semanticNodeKeys.icon(sectionHeadingNodeKey, "section")}
name={sectionIcon as IconName}
titleLineHeight={titleLineBox}
/>
<Heading
bindSemanticNode={false}
style={getSectionHeadingTextStyle(
sectionHeadingStyle,
sectionHeadingTextOf(sectionHeadingResolved.style),
)}
>
<Heading bindSemanticNode={false} style={sectionHeadingTextStyle}>
{sectionTitle}
</Heading>
</View>
@@ -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 = (
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
<WebsiteContactItem website={basics.website} style={styles.contactItem} />
<EmailContactItem email={basics.email} style={styles.contactItem} textStyle={styles.contactText} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} textStyle={styles.contactText} />
<LocationContactItem location={basics.location} style={styles.contactItem} textStyle={styles.contactText} />
<WebsiteContactItem website={basics.website} style={styles.contactItem} textStyle={styles.contactText} />
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
<CustomFieldContactItem
key={field.id}
field={field}
style={styles.contactItem}
textStyle={styles.contactText}
/>
))}
</SemanticContactListView>
);