diff --git a/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts b/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts index 977db0a38..4ef5ea616 100644 --- a/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts +++ b/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts @@ -114,7 +114,7 @@ describe("Semantic CSS editor extensions", () => { const labels = completionLabels(source, source.length, metadata); expect(labels).toEqual(expect.arrayContaining(["1pt dotted", "1pt dashed", "1pt solid"])); - expect(labels).not.toEqual(expect.arrayContaining(["pt", "px", "in", "mm", "cm", "%", "vw", "vh", "em", "rem"])); + expect(labels).not.toEqual(expect.arrayContaining(["pt", "px", "in", "mm", "cm", "%", "em", "rem"])); }); it("escapes dynamic IDs and attribute values before inserting selectors", () => { diff --git a/docs/applying-custom-styles.mdx b/docs/applying-custom-styles.mdx index e9f3958c5..2a6e7444f 100644 --- a/docs/applying-custom-styles.mdx +++ b/docs/applying-custom-styles.mdx @@ -193,8 +193,8 @@ assign a value to a `--resume-*` variable; create an author variable such as `-- | Page and layout | `--resume-page-gap-x`, `--resume-page-gap-y`, `--resume-page-margin-x`, `--resume-page-margin-y`, `--resume-page-width`, `--resume-page-height`, `--resume-sidebar-width` | | Picture | `--resume-picture-size`, `--resume-picture-rotation`, `--resume-picture-aspect-ratio`, `--resume-picture-border-radius`, `--resume-picture-border-width`, `--resume-picture-border-color`, `--resume-picture-shadow-width`, `--resume-picture-shadow-color` | -Use `pt` for predictable PDF spacing and type sizes. Semantic CSS also accepts `px`, `in`, `mm`, `cm`, `%`, `vw`, `vh`, `em`, -and `rem` where the property supports a length. +Use `pt` for predictable PDF spacing and type sizes. Semantic CSS also accepts `px`, `in`, `mm`, `cm`, `%`, `em`, and `rem` +where the property supports a length. ## Style common resume content diff --git a/packages/pdf/src/semantic/adapter.test.ts b/packages/pdf/src/semantic/adapter.test.ts index c77ed23f1..ea48ec6b1 100644 --- a/packages/pdf/src/semantic/adapter.test.ts +++ b/packages/pdf/src/semantic/adapter.test.ts @@ -15,15 +15,7 @@ import { adaptResolvedPdfNode, resolvedPdfFlowProps, resolvedPdfTextProps } from const pictureFixture = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; const borderShorthands = ["border", "border-top", "border-right", "border-bottom", "border-left"] as const; -const borderShorthandHints = [ - "inherit", - "initial", - "revert", - "unset", - "1pt dotted", - "1pt dashed", - "1pt solid", -] as const; +const borderShorthandHints = ["inherit", "initial", "1pt dotted", "1pt dashed", "1pt solid"] as const; const blankStyle: ResolvedNodeStyle = { style: {}, structural: {}, hidden: false, order: 0 }; const baseSettings: BaseSettingsSnapshot = { picture: defaultResumeData.picture, @@ -147,7 +139,7 @@ describe("adaptResolvedPdfNode", () => { }); }); - it("preserves explicit values equal to the resolver base and distinguishes initial from host-base resets", () => { + it("preserves explicit values equal to the resolver base and emits initial resets", () => { const base = { style: { color: "#111111", "font-weight": "700" }, structural: {}, @@ -158,11 +150,11 @@ describe("adaptResolvedPdfNode", () => { ...base, style: { color: "#111111" }, specifiedStyleProperties: ["color", "font-weight"], - hostBaseStyleProperties: ["color"], } satisfies ResolvedNodeStyle; expect(adaptResolvedPdfNode(resolved, base)).toEqual({ style: { + color: "#111111", fontWeight: undefined, }, }); @@ -179,7 +171,6 @@ describe("adaptResolvedPdfNode", () => { ...base, style: { color: "#111111", "font-size": 10 }, specifiedStyleProperties: [], - hostBaseStyleProperties: [], } satisfies ResolvedNodeStyle; expect(adaptResolvedPdfNode(resolved, base)).toEqual({}); diff --git a/packages/pdf/src/semantic/adapter.ts b/packages/pdf/src/semantic/adapter.ts index f7200dc6b..6551317c5 100644 --- a/packages/pdf/src/semantic/adapter.ts +++ b/packages/pdf/src/semantic/adapter.ts @@ -47,13 +47,12 @@ const toReactPdfProperty = (property: string) => { const styleDelta = (resolved: ResolvedNodeStyle, base: ResolvedNodeStyle["style"] | undefined): Style | undefined => { const specified = new Set(resolved.specifiedStyleProperties); - const hostBase = new Set(resolved.hostBaseStyleProperties); const entries: [string, string | number | undefined][] = base === undefined ? Object.entries(resolved.style) - : Object.entries(resolved.style).filter(([property]) => !hostBase.has(property) && specified.has(property)); + : Object.entries(resolved.style).filter(([property]) => specified.has(property)); for (const property of specified) { - if (!hostBase.has(property) && !(property in resolved.style)) entries.push([property, undefined]); + if (!(property in resolved.style)) entries.push([property, undefined]); } if (entries.length === 0) return undefined; diff --git a/packages/pdf/src/semantic/base-reset-fidelity.test.tsx b/packages/pdf/src/semantic/base-reset-fidelity.test.tsx index 3e29f929c..83eb42059 100644 --- a/packages/pdf/src/semantic/base-reset-fidelity.test.tsx +++ b/packages/pdf/src/semantic/base-reset-fidelity.test.tsx @@ -66,7 +66,7 @@ const finalTextStyle = async (template: Template, text: string, rule = "") => { return mergedStyle(findText(instance.container.document as HostNode, text)); }; -const finalOnyxCompanyStyle = async (keyword?: "inherit" | "initial" | "revert" | "unset") => { +const finalOnyxCompanyStyle = async (keyword?: "inherit" | "initial") => { const data = structuredClone(defaultResumeData); data.picture.hidden = true; data.metadata.typography.body.fontWeights = ["400", "500"]; @@ -103,14 +103,11 @@ describe("PDF semantic base and reset fidelity", () => { }); }); - it.each(["inherit", "unset", "revert"])( - "resets Bronzor's heading weight with %s against the actual host base", - async (keyword) => { - expect( - await finalTextStyle("bronzor", "Expertise", `section-heading { font-weight: ${keyword}; }`), - ).toMatchObject({ fontWeight: "400" }); - }, - ); + it("resets Bronzor's heading weight with inherit against the actual host base", async () => { + expect(await finalTextStyle("bronzor", "Expertise", "section-heading { font-weight: inherit; }")).toMatchObject({ + fontWeight: "400", + }); + }); it("cancels Bronzor's heading weight with the CSS initial value", async () => { expect(await finalTextStyle("bronzor", "Expertise", "section-heading { font-weight: initial; }")).toMatchObject({ @@ -125,38 +122,25 @@ describe("PDF semantic base and reset fidelity", () => { }); }); - it.each(["inherit", "unset"])( - "cancels Chikorita's sidebar field color with %s and emits the inherited parent value", - async (keyword) => { - expect( - await finalTextStyle("chikorita", "TypeScript", `field[name='name'] { color: ${keyword}; }`), - ).toMatchObject({ color: "#111111" }); - }, - ); - - it("restores Chikorita's sidebar field color with revert", async () => { - expect(await finalTextStyle("chikorita", "TypeScript", "field[name='name'] { color: revert; }")).toMatchObject({ - color: "#eeeeee", + it("cancels Chikorita's sidebar field color with inherit and emits the inherited parent value", async () => { + expect(await finalTextStyle("chikorita", "TypeScript", "field[name='name'] { color: inherit; }")).toMatchObject({ + color: "#111111", }); }); - it.each(["inherit", "unset"] as const)( - "cancels Onyx's local company weight with %s and emits the inherited parent value", - async (keyword) => { - expect(await finalOnyxCompanyStyle(keyword)).toMatchObject({ fontWeight: "400" }); - }, - ); + it("cancels Onyx's local company weight with inherit and emits the inherited parent value", async () => { + expect(await finalOnyxCompanyStyle("inherit")).toMatchObject({ fontWeight: "400" }); + }); it("cancels Onyx's local company weight with initial", async () => { expect(await finalOnyxCompanyStyle("initial")).toMatchObject({ fontWeight: undefined }); }); - it("restores Onyx's local company weight with revert", async () => { + it("keeps Onyx's local company weight without a reset", async () => { // The local value is the template's bold weight for the body family: // IBM Plex Serif stored as ["400", "500"] resolves to its true Bold - // face (#3310) — still distinct from the inherited 400 and the initial - // undefined, so the reset-keyword contract below stays verifiable. + // face (#3310) — distinct from the inherited 400 and the initial undefined, + // so the reset-keyword contract above stays verifiable. expect(await finalOnyxCompanyStyle()).toMatchObject({ fontWeight: "700" }); - expect(await finalOnyxCompanyStyle("revert")).toMatchObject({ fontWeight: "700" }); }); }); diff --git a/packages/pdf/src/semantic/item-header-row.test.tsx b/packages/pdf/src/semantic/item-header-row.test.tsx index df75590f3..7cbb2133f 100644 --- a/packages/pdf/src/semantic/item-header-row.test.tsx +++ b/packages/pdf/src/semantic/item-header-row.test.tsx @@ -3,7 +3,6 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { Template } from "@reactive-resume/schema/templates"; import { describe, expect, it } from "vitest"; import { createElement } from "react"; -import { analyzeStylesheet, compileStylesheet } from "@reactive-resume/resume/stylesheet"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { ResumeDocument } from "../document"; import { pdf } from "../forme/testing"; @@ -129,14 +128,12 @@ describe("item-header-row template part", () => { }); it("reports no diagnostics for the selector", () => { - const data = buildFixture(); - const page = data.metadata.layout.pages[0]; - if (!page) throw new Error("Missing authored page"); - const tree = buildSemanticTree({ data, template: "onyx", page, pageNumber: 1, showHeader: true }); - const compiled = compileStylesheet({ languageVersion: 1, text: NOWRAP_STYLESHEET }); - if (!compiled.program) throw new Error("Stylesheet failed to compile"); + const runtime = resolveResumeRuntime({ + data: buildFixture(), + template: "onyx", + source: { languageVersion: 1, text: NOWRAP_STYLESHEET }, + }); - expect(compiled.diagnostics).toEqual([]); - expect(analyzeStylesheet(compiled.program, tree)).toEqual([]); + expect(runtime.diagnostics).toEqual([]); }); }); diff --git a/packages/pdf/src/semantic/pagination-cancellation.test.tsx b/packages/pdf/src/semantic/pagination-cancellation.test.tsx index 9f8e0cb99..7234a10d8 100644 --- a/packages/pdf/src/semantic/pagination-cancellation.test.tsx +++ b/packages/pdf/src/semantic/pagination-cancellation.test.tsx @@ -65,9 +65,7 @@ describe("semantic pagination cancellation", () => { it.each([ ["auto", false, true], ["initial", false, true], - ["unset", false, true], ["inherit", false, true], - ["revert", true, false], ] as const)("maps %s over builder pagination to explicit break=%s and wrap=%s", (value, breakBefore, wrap) => { const data = buildFixture(value); const presentation = resolveResumePresentation({ data, template: "onyx" }); @@ -79,8 +77,6 @@ describe("semantic pagination cancellation", () => { it.each([ ["auto", false, true], ["initial", false, true], - ["unset", false, true], - ["revert", true, false], ] as const)("puts the %s cancellation on the final existing section View", async (value, breakBefore, wrap) => { const data = buildFixture(value); const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters[0]; diff --git a/packages/resume/src/stylesheet/analyze.test.ts b/packages/resume/src/stylesheet/analyze.test.ts deleted file mode 100644 index 277e7e070..000000000 --- a/packages/resume/src/stylesheet/analyze.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { SemanticNode } from "./types"; -import { describe, expect, it } from "vitest"; -import { analyzeStylesheet } from "./analyze"; -import { compileStylesheet } from "./compile"; -import { SEMANTIC_CSS_LIMITS_V1 } from "./limits"; - -const tree: SemanticNode = { - key: "resume", - kind: "resume", - attributes: { template: "onyx" }, - roles: [], - children: [ - { - key: "name", - kind: "name", - attributes: {}, - roles: ["primary-text"], - children: [], - }, - { - key: "picture", - kind: "picture", - attributes: {}, - roles: ["picture"], - children: [], - }, - ], -}; - -function compile(source: string) { - const result = compileStylesheet({ languageVersion: 1, text: source }); - if (!result.program) throw new Error(result.diagnostics.map(({ code }) => code).join(",")); - return result.program; -} - -function semanticTreeOfSize(size: number, shape: "deep" | "wide"): SemanticNode { - if (shape === "wide") { - return { - key: "root", - kind: "resume", - attributes: {}, - roles: [], - children: Array.from({ length: size - 1 }, (_, index) => ({ - key: `item-${index}`, - kind: "item", - attributes: {}, - roles: [], - children: [], - })), - }; - } - - let root: SemanticNode = { key: "node-0", kind: "item", attributes: {}, roles: [], children: [] }; - for (let index = 1; index < size; index++) { - root = { key: `node-${index}`, kind: "item", attributes: {}, roles: [], children: [root] }; - } - return root; -} - -function oversizedFrontierTree(): { tree: SemanticNode; childReads: () => number } { - let childReads = 0; - const children = new Proxy({} as readonly SemanticNode[], { - get: (_target, property) => { - if (property === "length") return SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes + 1; - if (property === Symbol.iterator || (typeof property === "string" && /^\d+$/.test(property))) { - childReads++; - throw new Error("Oversized frontier entries must not be read."); - } - }, - }); - return { - tree: { key: "oversized-root", kind: "resume", attributes: {}, roles: [], children }, - childReads: () => childReads, - }; -} - -describe("Semantic CSS semantic analysis", () => { - it("warns about selectors that match no immutable semantic node", () => { - const program = compile('@version 1; section[type="education"] { color: red; }'); - const diagnostics = analyzeStylesheet(program, tree); - - expect(diagnostics).toContainEqual(expect.objectContaining({ code: "SELECTOR_NO_MATCH", severity: "warning" })); - }); - - it("warns when a known property cannot apply to any matched node kind", () => { - const program = compile("@version 1; picture { color: red; }"); - const diagnostics = analyzeStylesheet(program, tree); - - expect(diagnostics).toContainEqual( - expect.objectContaining({ code: "PROPERTY_NOT_APPLICABLE", severity: "warning" }), - ); - }); - - it("does not warn for a selector and declaration with a real target", () => { - const program = compile("@version 1; name { color: red; }"); - - expect(analyzeStylesheet(program, tree)).toEqual([]); - }); - - it("accepts the exact analysis node budget and rejects deep or wide trees one node over", () => { - const program = { languageVersion: 1, rules: [] }; - expect(analyzeStylesheet(program, semanticTreeOfSize(SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes, "wide"))).toEqual([]); - - for (const shape of ["deep", "wide"] as const) { - expect( - analyzeStylesheet(program, semanticTreeOfSize(SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes + 1, shape)), - ).toContainEqual(expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" })); - } - }); - - it("rejects an oversized root frontier without reading or queueing child entries", () => { - const frontier = oversizedFrontierTree(); - const diagnostics = analyzeStylesheet({ languageVersion: 1, rules: [] }, frontier.tree); - - expect(diagnostics).toContainEqual(expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" })); - expect(frontier.childReads()).toBe(0); - }); - - it("warns about declarations the PDF engine can't draw, and only those", () => { - const diagnostics = analyzeStylesheet( - compile( - "@version 1;\nname { text-indent: 4pt; border: 1pt dashed #000; color: red; } picture { border-style: solid; }", - ), - tree, - ); - const unsupported = diagnostics.filter(({ code }) => code === "ENGINE_UNSUPPORTED"); - - expect(unsupported.map(({ message }) => message)).toEqual([ - expect.stringContaining("text-indent"), - expect.stringContaining("dashed"), - ]); - expect(unsupported.every(({ severity }) => severity === "warning")).toBe(true); - }); -}); diff --git a/packages/resume/src/stylesheet/analyze.ts b/packages/resume/src/stylesheet/analyze.ts deleted file mode 100644 index efbf6de81..000000000 --- a/packages/resume/src/stylesheet/analyze.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { SemanticCssDiagnostic, SemanticNode, StyleProgram } from "./types"; -import { createDiagnostic } from "./diagnostics"; -import { SEMANTIC_CSS_LIMITS_V1 } from "./limits"; -import { isEngineSupportedDeclaration, PROPERTY_REGISTRY_V1 } from "./registry/properties"; -import { createSelectorMatcher } from "./selector"; - -function flatten(root: SemanticNode): SemanticNode[] | null { - const nodes: SemanticNode[] = []; - const stack = [root]; - while (stack.length > 0) { - const node = stack.pop(); - if (!node) break; - if (nodes.length >= SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes) return null; - nodes.push(node); - const childCount = node.children.length; - if (childCount > SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes - nodes.length - stack.length) return null; - for (let index = 0; index < childCount; index++) { - const child = node.children[index]; - if (child) stack.push(child); - } - } - return nodes; -} - -export function analyzeStylesheet(program: StyleProgram, tree: SemanticNode): readonly SemanticCssDiagnostic[] { - const diagnostics: SemanticCssDiagnostic[] = []; - const nodes = flatten(tree); - if (!nodes) { - return [createDiagnostic("RESOURCE_LIMIT", "error", "The semantic tree exceeds the Semantic CSS node limit.")]; - } - const matchesSelector = createSelectorMatcher(tree); - for (const rule of program.rules) { - const matches = nodes.filter((node) => matchesSelector(rule.selector, node.key)); - if (matches.length === 0) { - diagnostics.push( - createDiagnostic("SELECTOR_NO_MATCH", "warning", "This selector matches no semantic resume node.", rule.range), - ); - continue; - } - for (const declaration of rule.declarations) { - if (declaration.property.startsWith("--")) continue; - const definition = PROPERTY_REGISTRY_V1[declaration.property]; - if (definition && !matches.some((node) => definition.appliesTo.includes(node.kind))) { - diagnostics.push( - createDiagnostic( - "PROPERTY_NOT_APPLICABLE", - "warning", - `${declaration.property} cannot apply to the matched semantic node kinds.`, - declaration.range, - ), - ); - } - // A shorthand expands to longhands that share its source range; one warning covers them. - const warned = diagnostics.some( - ({ code, range }) => code === "ENGINE_UNSUPPORTED" && range?.start.offset === declaration.range.start.offset, - ); - if (definition && !warned && !isEngineSupportedDeclaration(declaration.property, declaration.value)) { - diagnostics.push( - createDiagnostic( - "ENGINE_UNSUPPORTED", - "warning", - `The PDF engine can't draw ${declaration.property}: ${declaration.value} yet, so it has no effect.`, - declaration.range, - ), - ); - } - } - } - return diagnostics; -} diff --git a/packages/resume/src/stylesheet/cascade.test.ts b/packages/resume/src/stylesheet/cascade.test.ts index 57b593565..ddec29875 100644 --- a/packages/resume/src/stylesheet/cascade.test.ts +++ b/packages/resume/src/stylesheet/cascade.test.ts @@ -271,9 +271,6 @@ describe("Semantic CSS cascade and structural resolution", () => { `); expect(valid.nodes["heading-experience"]?.style.color).toBe(baseSettings.design.colors.primary); - const reverted = resolve("section { color: red; } section-heading { color: revert; }"); - expect(reverted.nodes["heading-experience"]?.style.color).toBe("black"); - const compiled = compileStylesheet({ languageVersion: 1, text: "@version 1; :root { --a: var(--b); --b: var(--a); } section { color: var(--a); }", @@ -392,10 +389,10 @@ describe("Semantic CSS cascade and structural resolution", () => { expect(invalid.diagnostics).toContainEqual(expect.objectContaining({ code: "MEDIA_PAGE_SIZE", severity: "error" })); }); - it("resolves a relative authored page size exactly once against authored dimensions", () => { + it("resolves an authored page size through a variable exactly once", () => { const result = resolve( ` - :root { --page-size: 50vw 50vh; } + :root { --page-size: 400pt 300pt; } page { size: var(--page-size); } @media (width: 400pt) { :root { --page-size: var(--missing); } } `, @@ -437,8 +434,6 @@ describe("Semantic CSS cascade and structural resolution", () => { it.each([ { keyword: "InItIaL", color: undefined, hidden: false, order: 0, fixed: undefined, breakBefore: undefined }, - { keyword: "uNsEt", color: "purple", hidden: false, order: 0, fixed: undefined, breakBefore: undefined }, - { keyword: "ReVeRt", color: "navy", hidden: true, order: 7, fixed: true, breakBefore: "page" }, { keyword: "InHeRiT", color: "purple", hidden: true, order: 3, fixed: true, breakBefore: "page" }, ] as const)( "applies case-insensitive $keyword semantics to style, hidden, order, and structural properties", @@ -479,20 +474,9 @@ describe("Semantic CSS cascade and structural resolution", () => { }, ); - it("makes size revert expose the builder page size", () => { - const result = resolve("page { size: ReVeRt; }", { - ...context, - pages: [{ pageKey: "page-1", width: 800, height: 600 }], - }); - - expect(result.nodes["page-1"]?.structural.pageSize).toBe("A4"); - }); - it.each([ { keyword: "initial", expected: undefined }, - { keyword: "unset", expected: undefined }, { keyword: "inherit", expected: "LETTER" }, - { keyword: "revert", expected: { width: 700, height: 900 } }, ] as const)("applies $keyword to page size structure", ({ keyword, expected }) => { const result = resolve(`page { size: ${keyword}; }`, { ...context, diff --git a/packages/resume/src/stylesheet/cascade.ts b/packages/resume/src/stylesheet/cascade.ts index b8c011e32..91e44c22b 100644 --- a/packages/resume/src/stylesheet/cascade.ts +++ b/packages/resume/src/stylesheet/cascade.ts @@ -44,13 +44,12 @@ type Winner = { }; type LengthContext = { - page: ResolvedPageDimensions; parentFontSize: number; fontSize: number; rootFontSize: number; }; -type CssWideKeyword = "inherit" | "initial" | "revert" | "unset"; +type CssWideKeyword = "inherit" | "initial"; type VariableExpansionBudget = { work: number; @@ -64,7 +63,7 @@ const absoluteUnitToPt = { cm: 72 / 2.54, } as const; -const cssWideKeywords = new Set(["inherit", "initial", "revert", "unset"]); +const cssWideKeywords = new Set(["inherit", "initial"]); const maxVariableExpansionOutputCodeUnits = SEMANTIC_CSS_LIMITS_V1.maxSourceBytes; const maxVariableExpansionWorkCodeUnits = SEMANTIC_CSS_LIMITS_V1.maxSourceBytes * 4; @@ -161,16 +160,12 @@ function pageFor( } function toPoints(value: string, property: string, context: LengthContext): number | string | null { - const match = value - .trim() - .match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*(pt|px|in|mm|cm|%|vw|vh|em|rem)?$/i); + const match = value.trim().match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*(pt|px|in|mm|cm|%|em|rem)?$/i); if (!match) return null; const number = Number(match[1]); if (!Number.isFinite(number)) return null; const unit = match[2]?.toLowerCase() ?? "pt"; if (unit === "%") return property === "font-size" ? (number / 100) * context.parentFontSize : `${number}%`; - if (unit === "vw") return (number / 100) * context.page.width; - if (unit === "vh") return (number / 100) * context.page.height; if (unit === "rem") return number * context.rootFontSize; if (unit === "em") { return number * (property === "font-size" ? context.parentFontSize : context.fontSize); @@ -180,7 +175,6 @@ function toPoints(value: string, property: string, context: LengthContext): numb function mediaMatches(query: CompiledMediaQuery, dimensions: ResolvedPageDimensions, rootFontSize: number): boolean { const lengthContext = { - page: dimensions, parentFontSize: rootFontSize, fontSize: rootFontSize, rootFontSize, @@ -415,7 +409,7 @@ function customProperties( for (const [property, { declaration }] of winners) { if (!property.startsWith("--")) continue; const keyword = cssWideKeyword(declaration.value); - if (keyword === "inherit" || keyword === "revert" || keyword === "unset") continue; + if (keyword === "inherit") continue; if (keyword === "initial") { if (parent?.has(property)) { custom ??= new Map(parent); @@ -453,24 +447,12 @@ function dimensionsForSize(size: ResolvedPageSize, authored: ResolvedPageDimensi return { width: size.width, height: size.height ?? authored.height }; } -function builderPageSize(context: ResolveStylesheetContext, nodeKey: string): ResolvedPageSize { - return ( - context.baseStyles[nodeKey]?.structural.pageSize ?? - (context.baseSettings.page.format === "letter" ? "LETTER" : "A4") - ); -} - function cssWideValue( keyword: CssWideKeyword, property: string, - base: Readonly>, parent: Readonly> | undefined, ): string | number | undefined { - const definition = PROPERTY_REGISTRY_V1[property]; - if (keyword === "inherit") return parent?.[property]; - if (keyword === "initial") return; - if (keyword === "unset") return definition?.inheritable ? parent?.[property] : undefined; - return base[property] ?? (definition?.inheritable ? parent?.[property] : undefined); + return keyword === "inherit" ? parent?.[property] : undefined; } function normalizeValue(property: string, value: string, context: LengthContext): string | number | null { @@ -499,18 +481,11 @@ function applyStructuralCssWide( keyword: CssWideKeyword, property: string, structural: StructuralPresentation, - base: ResolvedNodeStyle, parent: ResolvedNodeStyle | undefined, - revertPageSize: ResolvedPageSize, ): boolean { const key = structuralKeys[property]; if (!key) return false; - const value = - keyword === "inherit" - ? parent?.structural[key] - : keyword === "revert" - ? (base.structural[key] ?? (key === "pageSize" ? revertPageSize : undefined)) - : undefined; + const value = keyword === "inherit" ? parent?.structural[key] : undefined; if (value === undefined) delete structural[key]; else Object.assign(structural, { [key]: value }); return true; @@ -684,20 +659,13 @@ export function resolveStylesheet( if (!expanded) continue; const keyword = cssWideKeyword(expanded); const parsed = - keyword === "revert" - ? builderPageSize(context, node.node.key) - : keyword === "inherit" - ? node.parent - ? context.baseStyles[node.parent.node.key]?.structural.pageSize - : undefined - : keyword - ? undefined - : parsePageSize(expanded, { - page: dimensions, - parentFontSize: rootFontSize, - fontSize: rootFontSize, - rootFontSize, - }); + keyword === "inherit" + ? node.parent + ? context.baseStyles[node.parent.node.key]?.structural.pageSize + : undefined + : keyword + ? undefined + : parsePageSize(expanded, { parentFontSize: rootFontSize, fontSize: rootFontSize, rootFontSize }); if (keyword && !parsed) { resolvedPageSizeValues.set(node.node.key, expanded); continue; @@ -732,13 +700,11 @@ export function resolveStylesheet( const parent = node.parent ? resolved[node.parent.node.key] : undefined; const style: Record = { ...base.style }; const specifiedStyleProperties = new Set(); - const hostBaseStyleProperties = new Set(); for (const [property, definition] of Object.entries(PROPERTY_REGISTRY_V1)) { if (!definition?.inheritable) continue; const inheritedFromAuthoredRule = parent?.specifiedStyleProperties?.includes(property) && !winners.has(property); if (inheritedFromAuthoredRule) { specifiedStyleProperties.add(property); - if (parent?.hostBaseStyleProperties?.includes(property)) hostBaseStyleProperties.add(property); if (parent?.style[property] === undefined) delete style[property]; else style[property] = parent.style[property]; } else if (style[property] === undefined && parent?.style[property] !== undefined) { @@ -757,8 +723,7 @@ export function resolveStylesheet( const keyword = cssWideKeyword(expanded); if (keyword) { specifiedStyleProperties.add("font-size"); - if (keyword === "revert") hostBaseStyleProperties.add("font-size"); - const wide = cssWideValue(keyword, "font-size", base.style, parent?.style); + const wide = cssWideValue(keyword, "font-size", parent?.style); if (wide === undefined) delete style["font-size"]; else style["font-size"] = wide; } else { @@ -767,7 +732,6 @@ export function resolveStylesheet( diagnostics.push(createDiagnostic("INVALID_VALUE", "error", syntaxError, fontSizeWinner.declaration.range)); } else { const normalized = normalizeValue("font-size", expanded, { - page: dimensions, parentFontSize, fontSize: parentFontSize, rootFontSize, @@ -820,14 +784,7 @@ export function resolveStylesheet( if (resolvedValue === undefined) continue; const sizeKeyword = cssWideKeyword(resolvedValue); if (sizeKeyword) { - applyStructuralCssWide( - sizeKeyword, - property, - structural, - base, - parent, - builderPageSize(context, node.node.key), - ); + applyStructuralCssWide(sizeKeyword, property, structural, parent); } else { const pageSize = resolvedPageSizes.get(node.node.key); if (pageSize) structuralValue(property, resolvedValue, structural, pageSize); @@ -841,22 +798,17 @@ export function resolveStylesheet( hidden = parent?.hidden ?? false; if (!hidden && parent?.style.display !== undefined) style.display = parent.style.display; else delete style.display; - } else if (keyword === "revert") { - hidden = base.hidden; - if (base.style.display === undefined) delete style.display; - else style.display = base.style.display; } else { hidden = false; delete style.display; } } else if (property === "order") { - order = keyword === "inherit" ? (parent?.order ?? 0) : keyword === "revert" ? base.order : 0; + order = keyword === "inherit" ? (parent?.order ?? 0) : 0; } else if (definition.category === "structural") { - applyStructuralCssWide(keyword, property, structural, base, parent, builderPageSize(context, node.node.key)); + applyStructuralCssWide(keyword, property, structural, parent); } else { specifiedStyleProperties.add(property); - if (keyword === "revert") hostBaseStyleProperties.add(property); - const wide = cssWideValue(keyword, property, base.style, parent?.style); + const wide = cssWideValue(keyword, property, parent?.style); if (wide === undefined) delete style[property]; else style[property] = wide; } @@ -869,7 +821,6 @@ export function resolveStylesheet( } const normalized = normalizeValue(property, expanded, { - page: dimensions, parentFontSize, fontSize, rootFontSize, @@ -920,7 +871,6 @@ export function resolveStylesheet( resolved[node.node.key] = { style, specifiedStyleProperties: [...specifiedStyleProperties], - hostBaseStyleProperties: [...hostBaseStyleProperties], structural, hidden, order, diff --git a/packages/resume/src/stylesheet/diagnostics.ts b/packages/resume/src/stylesheet/diagnostics.ts index bf5a0df55..30bb02d87 100644 --- a/packages/resume/src/stylesheet/diagnostics.ts +++ b/packages/resume/src/stylesheet/diagnostics.ts @@ -93,11 +93,6 @@ export const SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 = { meaning: "The property cannot affect any matched semantic node kind.", action: "Target a node listed in the property's Applies to column.", }, - ENGINE_UNSUPPORTED: { - severity: "warning", - meaning: "The declaration is valid Semantic CSS, but the PDF engine can't draw it yet, so it has no effect.", - action: "Remove the declaration or reach the same look with a supported property.", - }, RESOURCE_LIMIT: { severity: "error", meaning: "Compilation, matching, values, variables, or semantic nodes exceeded a bounded Semantic CSS limit.", diff --git a/packages/resume/src/stylesheet/index.ts b/packages/resume/src/stylesheet/index.ts index b4a9e9359..b20002c13 100644 --- a/packages/resume/src/stylesheet/index.ts +++ b/packages/resume/src/stylesheet/index.ts @@ -32,7 +32,6 @@ export type { StructuralPresentation, StyleProgram, } from "./types"; -export { analyzeStylesheet } from "./analyze"; export { resolveStylesheet } from "./cascade"; export { compileStylesheet } from "./compile"; export { isFatalStylesheetDiagnostic, SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 } from "./diagnostics"; diff --git a/packages/resume/src/stylesheet/registry/index.ts b/packages/resume/src/stylesheet/registry/index.ts index 79c388e74..27d90ee8c 100644 --- a/packages/resume/src/stylesheet/registry/index.ts +++ b/packages/resume/src/stylesheet/registry/index.ts @@ -9,11 +9,9 @@ export type { SystemVariableDefinition, SystemVariableRegistry } from "./system- export { escapeCssIdentifier, escapeCssString } from "../css-escape"; export { SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 } from "../diagnostics"; export { - isEngineSupportedDeclaration, PROPERTY_REGISTRY_V1, SEMANTIC_CSS_BORDER_STYLE_VALUES_V1, SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1, - SEMANTIC_CSS_ENGINE_UNSUPPORTED_V1, SEMANTIC_CSS_LENGTH_PROPERTIES_V1, SEMANTIC_CSS_LENGTH_UNITS_V1, SEMANTIC_CSS_LENGTH_VALUE_KEYWORDS_V1, diff --git a/packages/resume/src/stylesheet/registry/properties.test.ts b/packages/resume/src/stylesheet/registry/properties.test.ts index 0452197a0..9a9cdd3b5 100644 --- a/packages/resume/src/stylesheet/registry/properties.test.ts +++ b/packages/resume/src/stylesheet/registry/properties.test.ts @@ -2,15 +2,7 @@ import { describe, expect, it } from "vitest"; import { PROPERTY_REGISTRY_V1 } from "./properties"; const borderShorthands = ["border", "border-top", "border-right", "border-bottom", "border-left"] as const; -const borderShorthandHints = [ - "inherit", - "initial", - "revert", - "unset", - "1pt dotted", - "1pt dashed", - "1pt solid", -] as const; +const borderShorthandHints = ["inherit", "initial", "1pt dotted", "1pt dashed", "1pt solid"] as const; const expectedProperties = [ "align-content", @@ -365,28 +357,18 @@ describe("property registry", () => { }); it("publishes property-specific fixed value hints", () => { - expect(PROPERTY_REGISTRY_V1["border-style"]?.values).toEqual([ - "inherit", - "initial", - "revert", - "unset", - "dotted", - "dashed", - "solid", - ]); + expect(PROPERTY_REGISTRY_V1["border-style"]?.values).toEqual(["inherit", "initial", "dotted", "dashed", "solid"]); expect(PROPERTY_REGISTRY_V1["object-fit"]?.values).toEqual([ "inherit", "initial", - "revert", - "unset", "contain", "cover", "fill", "none", "scale-down", ]); - expect(PROPERTY_REGISTRY_V1["font-size"]?.values).toEqual(["inherit", "initial", "revert", "unset"]); - expect(PROPERTY_REGISTRY_V1.gap?.values).toEqual(["inherit", "initial", "revert", "unset"]); + expect(PROPERTY_REGISTRY_V1["font-size"]?.values).toEqual(["inherit", "initial"]); + expect(PROPERTY_REGISTRY_V1.gap?.values).toEqual(["inherit", "initial"]); }); it.each(borderShorthands)("publishes only complete value hints for the %s shorthand", (property) => { diff --git a/packages/resume/src/stylesheet/registry/properties.ts b/packages/resume/src/stylesheet/registry/properties.ts index 7800eb894..af1bf9e0a 100644 --- a/packages/resume/src/stylesheet/registry/properties.ts +++ b/packages/resume/src/stylesheet/registry/properties.ts @@ -21,8 +21,8 @@ export type PropertyDefinition = { export type PropertyRegistry = Readonly>; -export const SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1 = ["inherit", "initial", "revert", "unset"] as const; -export const SEMANTIC_CSS_LENGTH_UNITS_V1 = ["pt", "px", "in", "mm", "cm", "%", "vw", "vh", "em", "rem"] as const; +export const SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1 = ["inherit", "initial"] as const; +export const SEMANTIC_CSS_LENGTH_UNITS_V1 = ["pt", "px", "in", "mm", "cm", "%", "em", "rem"] as const; export const SEMANTIC_CSS_BORDER_STYLE_VALUES_V1 = ["dotted", "dashed", "solid"] as const; export const SEMANTIC_CSS_LENGTH_VALUE_KEYWORDS_V1 = [ "auto", @@ -342,45 +342,3 @@ const properties = { } satisfies PropertyRegistry; export const PROPERTY_REGISTRY_V1: PropertyRegistry = properties; - -/** - * What the PDF engine (Forme) can't draw yet, by property: `true` for any value, or a test for the values it can't. - * These stay valid Semantic CSS, so stylesheets keep working when the engine learns them; the editor warns instead. - */ -export const SEMANTIC_CSS_ENGINE_UNSUPPORTED_V1: Readonly boolean)>> = { - "z-index": true, - "max-lines": true, - "text-indent": true, - "vertical-align": true, - "object-position": true, - "text-decoration-color": true, - "text-decoration-style": true, - "-resume-min-presence-ahead": true, - transform: (value: string) => /rotate\(\s*-?(?:\d*\.)?\d*[1-9]/.test(value), - ...Object.fromEntries( - ["padding", "padding-top", "padding-right", "padding-bottom", "padding-left"].map((property) => [ - property, - (value: string) => value.includes("%"), - ]), - ), - ...Object.fromEntries( - [ - "border", - "border-top", - "border-right", - "border-bottom", - "border-left", - "border-style", - "border-top-style", - "border-right-style", - "border-bottom-style", - "border-left-style", - ].map((property) => [property, (value: string) => /\b(?:dashed|dotted)\b/i.test(value)]), - ), -}; - -/** Whether the PDF engine draws this declaration (see `SEMANTIC_CSS_ENGINE_UNSUPPORTED_V1`). */ -export function isEngineSupportedDeclaration(property: string, value: string): boolean { - const unsupported = SEMANTIC_CSS_ENGINE_UNSUPPORTED_V1[property]; - return unsupported === undefined || (unsupported !== true && !unsupported(value)); -} diff --git a/packages/resume/src/stylesheet/selector.test.ts b/packages/resume/src/stylesheet/selector.test.ts index a982f9951..9aad64b7a 100644 --- a/packages/resume/src/stylesheet/selector.test.ts +++ b/packages/resume/src/stylesheet/selector.test.ts @@ -107,9 +107,7 @@ describe("semantic selector compilation", () => { expect(matches("[type]", "section-experience")).toBe(true); expect(matches('[type="experience"]', "section-experience")).toBe(true); expect(matches('[role~="nested-role"]', "item-second")).toBe(true); - expect(matches('[origin|="custom"]', "section-experience")).toBe(true); expect(matches('[type^="exp"]', "section-experience")).toBe(true); - expect(matches('[type$="ence"]', "section-experience")).toBe(true); expect(matches('[type*="per"]', "section-experience")).toBe(true); }); @@ -117,7 +115,6 @@ describe("semantic selector compilation", () => { expect(getSpecificity(":where(#one) section")).toEqual([0, 0, 1]); expect(getSpecificity(":is(#one, section)")).toEqual([1, 0, 0]); expect(getSpecificity(":not([type]) section")).toEqual([0, 1, 1]); - expect(getSpecificity("item:nth-child(2 of #one, section)")).toEqual([1, 1, 1]); }); it.each([ @@ -136,6 +133,9 @@ describe("semantic selector compilation", () => { "page[type]", '[type="experience" i]', "svg|section", + '[origin|="custom"]', + '[type$="ence"]', + "item:nth-child(2 of section)", ])("rejects unsupported or unknown selector %s", (selector) => { expect(compileSelector(selector).selector).toBeNull(); }); diff --git a/packages/resume/src/stylesheet/selector.ts b/packages/resume/src/stylesheet/selector.ts index 9b8971d45..9ac6c20cc 100644 --- a/packages/resume/src/stylesheet/selector.ts +++ b/packages/resume/src/stylesheet/selector.ts @@ -8,7 +8,7 @@ import { SEMANTIC_NODE_KINDS, SEMANTIC_REGISTRY_V1 } from "./registry/semantic"; export type Specificity = readonly [ids: number, classes: number, types: number]; type Combinator = " " | ">" | "+" | "~"; -type AttributeMatcher = "=" | "~=" | "|=" | "^=" | "$=" | "*="; +type AttributeMatcher = "=" | "~=" | "^=" | "*="; type CompiledSimpleSelector = | { type: "universal" } @@ -26,7 +26,6 @@ type CompiledSimpleSelector = name: "nth-child" | "nth-of-type"; a: number; b: number; - of?: readonly CompiledComplexSelector[]; }; type CompiledCompoundSelector = { @@ -142,11 +141,7 @@ function validateCompound(selectors: readonly CompiledSimpleSelector[]): void { } } -function compileNth( - node: SelectorAst, - name: "nth-child" | "nth-of-type", - context: CompileContext, -): CompiledSimpleSelector { +function compileNth(node: SelectorAst, name: "nth-child" | "nth-of-type"): CompiledSimpleSelector { const nth = childrenOf(node); if (nth.length !== 1 || nth[0]?.type !== "Nth" || !nth[0].nth) { throw new Error(`:${name} requires one An+B expression.`); @@ -173,9 +168,8 @@ function compileNth( throw new Error(`Unsupported :${name} expression.`); } - if (name === "nth-of-type" && nth[0].selector) throw new Error(":nth-of-type does not accept an of selector."); - const of = nth[0].selector ? compileSelectorList(nth[0].selector, { depth: context.depth + 1 }) : undefined; - return { type: "pseudo", name, a, b, ...(of ? { of } : {}) }; + if (nth[0].selector) throw new Error(`:${name} does not accept an of selector.`); + return { type: "pseudo", name, a, b }; } function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimpleSelector | null { @@ -193,7 +187,7 @@ function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimp if (!knownAttributes.has(name)) throw new Error(`Unknown semantic attribute ${name}.`); if (node.flags) throw new Error("Attribute selector flags are not supported."); const matcher = node.matcher as AttributeMatcher | null | undefined; - if (matcher !== null && matcher !== undefined && !["=", "~=", "|=", "^=", "$=", "*="].includes(matcher)) { + if (matcher !== null && matcher !== undefined && !["=", "~=", "^=", "*="].includes(matcher)) { throw new Error(`Unsupported attribute matcher ${matcher}.`); } const value = attributeValue(node); @@ -220,12 +214,7 @@ function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimp const selectors = compileSelectorList(nested[0], { depth: context.depth + 1 }); return { type: "pseudo", name: name as "is" | "where" | "not", selectors }; } - if (name === "nth-child" || name === "nth-of-type") { - if (context.depth >= SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth) { - throw new SelectorResourceLimitError("Selector function nesting is too deep."); - } - return compileNth(node, name, context); - } + if (name === "nth-child" || name === "nth-of-type") return compileNth(node, name); throw new Error(`Unsupported pseudo-class :${name}.`); } case "ClassSelector": @@ -336,12 +325,8 @@ function matchesAttribute(actual: string, matcher: AttributeMatcher, expected: s return actual === expected; case "~=": return expected !== "" && actual.split(/\s+/).includes(expected); - case "|=": - return expected !== "" && (actual === expected || actual.startsWith(`${expected}-`)); case "^=": return expected !== "" && actual.startsWith(expected); - case "$=": - return expected !== "" && actual.endsWith(expected); case "*=": return expected !== "" && actual.includes(expected); } @@ -394,8 +379,6 @@ function matchesSimple(selector: CompiledSimpleSelector, target: TreeNode): bool if (selector.name === "nth-of-type") { values = values.filter((sibling) => sibling.node.kind === target.node.kind); } - if (selector.of) - values = values.filter((sibling) => selector.of?.some((nested) => matchesComplex(nested, sibling))); const index = values.indexOf(target); return index >= 0 && nthMatches(index + 1, selector.a, selector.b); } diff --git a/packages/resume/src/stylesheet/types.ts b/packages/resume/src/stylesheet/types.ts index 35656bfd5..e270fc1f8 100644 --- a/packages/resume/src/stylesheet/types.ts +++ b/packages/resume/src/stylesheet/types.ts @@ -76,7 +76,6 @@ export type AuthoredPageContext = { export type ResolvedNodeStyle = { style: Readonly>; specifiedStyleProperties?: readonly string[]; - hostBaseStyleProperties?: readonly string[]; structural: StructuralPresentation; hidden: boolean; order: number; diff --git a/packages/resume/src/stylesheet/values.ts b/packages/resume/src/stylesheet/values.ts index 357bdafee..19c0d86c8 100644 --- a/packages/resume/src/stylesheet/values.ts +++ b/packages/resume/src/stylesheet/values.ts @@ -510,9 +510,7 @@ function parseMedia(node: AstNode, diagnostics: SemanticCssDiagnostic[]): readon const invalidValue = queries.some((query) => query?.features.some((feature) => { if (feature.name === "orientation") return false; - const match = feature.value.match( - /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(?:pt|px|in|mm|cm|vw|vh|em|rem)?$/i, - ); + const match = feature.value.match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(?:pt|px|in|mm|cm|em|rem)?$/i); if (!match) { return true; }