import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet"; import type { Template } from "@reactive-resume/schema/templates"; import { pdf } from "@react-pdf/renderer"; import { createElement } from "react"; import { PROPERTY_REGISTRY_V1 } from "@reactive-resume/resume/stylesheet"; import { styleRulesSchema } from "@reactive-resume/schema/resume/data"; import { ResumeDocument } from "../document"; export type CompareLegacySemanticPresentationInput = { data: ResumeData; convertedSource: StylesheetSource; templates: readonly Template[]; }; export type LegacySemanticPresentationComparison = { pageCountMismatches: readonly string[]; primitivePropMismatches: readonly string[]; mismatches: readonly string[]; }; export type LegacyParityHostNode = { type: string; props?: Readonly>; style?: unknown; value?: string; children?: readonly LegacyParityHostNode[]; }; type PrimitiveSnapshot = { type: string; text: string; props: Readonly>; style: Readonly>; children: readonly PrimitiveSnapshot[]; }; const primitiveTypes = new Set(["PAGE", "VIEW", "TEXT", "LINK", "IMAGE", "SVG"]); const inheritableProperties = new Set( Object.entries(PROPERTY_REGISTRY_V1) .filter(([, definition]) => definition?.inheritable) .map(([property]) => property.replace(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase())), ); const textPrimitiveTypes = new Set(["TEXT", "LINK"]); const textOnlyInheritedProperties = new Set([ "fontFamily", "fontSize", "fontStyle", "fontWeight", "letterSpacing", "lineHeight", "textAlign", "textIndent", "textTransform", ]); const rendererInheritedProperties = new Set([ ...inheritableProperties, ...textOnlyInheritedProperties, "color", "direction", ]); const rendererDefaults: Readonly> = { fontWeight: 400 }; const emptyTextResetStyle: Readonly> = { flexShrink: 1, maxWidth: "100%", minWidth: 0, }; const nodeText = (node: LegacyParityHostNode): string => node.value ?? (node.children ?? []).map((child) => nodeText(child)).join(""); const normalizeValue = (value: unknown): unknown => { if (value === undefined) return "[undefined]"; if (typeof value === "function") return `[function:${value.name || "anonymous"}]`; if (Array.isArray(value)) return value.map(normalizeValue); if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value as Record) .filter(([, entry]) => entry !== undefined) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, entry]) => [key, normalizeValue(entry)]), ); } return value; }; const normalizeHexColor = (value: string): string | undefined => { const match = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(value); if (!match) return; const digits = match[1]?.toLowerCase(); if (!digits) return; return digits.length <= 4 ? `#${[...digits].map((digit) => `${digit}${digit}`).join("")}` : `#${digits}`; }; const normalizeRgbColor = (value: string): string | undefined => { const match = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(\d*\.?\d+)\s*)?\)$/i.exec(value); if (!match) return; const channels = match.slice(1, 4).map(Number); if (channels.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 255)) return; const alpha = match[4] === undefined ? 1 : Number(match[4]); if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) return; const hex = channels.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join(""); const alphaHex = Math.round(alpha * 255) .toString(16) .padStart(2, "0"); return `#${hex}${alphaHex === "ff" ? "" : alphaHex}`; }; const normalizeColor = (value: string): string => { const normalized = value.trim().toLowerCase(); if (normalized === "transparent") return "#00000000"; return normalizeHexColor(normalized) ?? normalizeRgbColor(normalized) ?? normalized; }; const normalizeStyleValue = (property: string, value: unknown): unknown => { if (typeof value === "string" && (property === "color" || property.endsWith("Color"))) { return normalizeColor(value); } if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)) return Number.parseFloat(value); return normalizeValue(value); }; const mergedStyle = (style: unknown): Readonly> => { const entries = Array.isArray(style) ? style : style ? [style] : []; const merged = Object.assign({}, ...entries) as Record; if (merged.borderWidth !== undefined) { for (const side of ["Top", "Right", "Bottom", "Left"]) { const property = `border${side}Width`; if (merged[property] === undefined) merged[property] = merged.borderWidth; } delete merged.borderWidth; } if (merged.borderRadius !== undefined) { for (const corner of ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]) { const property = `border${corner}Radius`; if (merged[property] === undefined) merged[property] = merged.borderRadius; } delete merged.borderRadius; } return Object.fromEntries( Object.entries(merged) .filter(([, value]) => value !== undefined) .sort(([left], [right]) => left.localeCompare(right)) .map(([property, value]) => [property, normalizeStyleValue(property, value)]), ); }; const collectPrimitiveSnapshots = ( node: LegacyParityHostNode, inheritedStyle: Readonly> = rendererDefaults, ): PrimitiveSnapshot[] => { const localStyle = mergedStyle(node.style); const computedStyle: Record = { ...localStyle }; for (const property of rendererInheritedProperties) { if (!(property in computedStyle) && property in inheritedStyle) computedStyle[property] = inheritedStyle[property]; } const nextInherited = Object.fromEntries( [...rendererInheritedProperties].flatMap((property) => property in computedStyle ? [[property, computedStyle[property]]] : [], ), ); const children = (node.children ?? []).flatMap((child) => collectPrimitiveSnapshots(child, nextInherited)); if (!primitiveTypes.has(node.type)) return children; const snapshotStyle = Object.fromEntries( Object.entries(computedStyle).filter( ([property]) => textPrimitiveTypes.has(node.type) || !textOnlyInheritedProperties.has(property), ), ); const props = Object.fromEntries( Object.entries(node.props ?? {}).filter(([property]) => property !== "children" && property !== "style"), ); const isInheritedOnlyStyle = Object.entries(snapshotStyle).every(([property, value]) => Object.is(value, inheritedStyle[property]), ); const hasCompleteEmptyTextReset = Object.entries(emptyTextResetStyle).every(([property, value]) => Object.is(snapshotStyle[property], value)) && (snapshotStyle.direction === "ltr" || snapshotStyle.direction === "rtl"); const isRendererEmptyTextStyle = hasCompleteEmptyTextReset && Object.entries(snapshotStyle).every(([property, value]) => { if (property === "direction") return true; if (property in emptyTextResetStyle) return Object.is(value, emptyTextResetStyle[property]); return Object.is(value, inheritedStyle[property]); }); const isPresentationNeutralEmptyText = node.type === "TEXT" && nodeText(node) === "" && children.length === 0 && Object.keys(props).length === 0 && (isInheritedOnlyStyle || isRendererEmptyTextStyle); if (isPresentationNeutralEmptyText) return []; return [ { type: node.type, text: nodeText(node), props: normalizeValue(props) as Readonly>, style: snapshotStyle, children, }, ]; }; const waitForDocument = async (instance: ReturnType): Promise => { const deadline = Date.now() + 5_000; while (!instance.container.document) { if (Date.now() >= deadline) throw new Error("Timed out while rendering legacy parity document."); await new Promise((resolve) => setTimeout(resolve, 0)); } return instance.container.document as LegacyParityHostNode; }; const renderSnapshots = async (data: ResumeData, template: Template): Promise => { const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters[0]; return collectPrimitiveSnapshots(await waitForDocument(pdf(element))); }; const arrayEntryLabel = (value: unknown): string => { if (!value || typeof value !== "object" || Array.isArray(value)) return ""; const entry = value as { type?: unknown; text?: unknown }; if (typeof entry.type !== "string") return ""; const text = typeof entry.text === "string" ? entry.text.replace(/\s+/g, " ").trim().slice(0, 32) : ""; return `<${entry.type}${text ? `:${JSON.stringify(text)}` : ""}>`; }; const diffValues = (legacy: unknown, semantic: unknown, path: string, mismatches: string[]): void => { if (Object.is(legacy, semantic)) return; if (Array.isArray(legacy) && Array.isArray(semantic)) { if (legacy.length !== semantic.length) { mismatches.push(`${path}.length: legacy=${legacy.length} semantic=${semantic.length}`); } for (let index = 0; index < Math.max(legacy.length, semantic.length); index++) { const label = arrayEntryLabel(legacy[index] ?? semantic[index]); diffValues(legacy[index], semantic[index], `${path}[${index}]${label}`, mismatches); } return; } if ( legacy && semantic && typeof legacy === "object" && typeof semantic === "object" && !Array.isArray(legacy) && !Array.isArray(semantic) ) { const keys = new Set([ ...Object.keys(legacy as Record), ...Object.keys(semantic as Record), ]); for (const key of [...keys].sort()) { diffValues( (legacy as Record)[key], (semantic as Record)[key], `${path}.${key}`, mismatches, ); } return; } mismatches.push(`${path}: legacy=${JSON.stringify(legacy)} semantic=${JSON.stringify(semantic)}`); }; export function compareLegacyParityHostNodes( legacy: LegacyParityHostNode, semantic: LegacyParityHostNode, ): readonly string[] { const mismatches: string[] = []; diffValues(collectPrimitiveSnapshots(legacy), collectPrimitiveSnapshots(semantic), "root", mismatches); return mismatches; } export async function compareLegacySemanticPresentation( input: CompareLegacySemanticPresentationInput, ): Promise { const sanitizedRules = styleRulesSchema.parse(input.data.metadata.styleRules ?? []); const { stylesheet: _stylesheet, ...metadata } = input.data.metadata; const legacyData: ResumeData = { ...input.data, metadata: { ...metadata, styleRules: sanitizedRules }, }; const semanticData: ResumeData = { ...input.data, metadata: { ...input.data.metadata, styleRules: sanitizedRules, stylesheet: { mode: "semantic", source: input.convertedSource, }, }, }; const pageCountMismatches: string[] = []; const primitivePropMismatches: string[] = []; for (const template of input.templates) { const legacy = await renderSnapshots(legacyData, template); const semantic = await renderSnapshots(semanticData, template); const legacyPages = legacy.filter(({ type }) => type === "PAGE").length; const semanticPages = semantic.filter(({ type }) => type === "PAGE").length; if (legacyPages !== semanticPages) { pageCountMismatches.push(`${template}: legacy=${legacyPages} semantic=${semanticPages}`); } diffValues(legacy, semantic, template, primitivePropMismatches); } return { pageCountMismatches, primitivePropMismatches, mismatches: [...pageCountMismatches, ...primitivePropMismatches], }; }