refactor(resume): drop semantic css features without a clear pdf meaning

Remove the revert and unset keywords, vw/vh units, the |= and $= attribute
matchers, :nth-child(... of ...), the stylesheet analyzer and the
engine-unsupported warnings. inherit and initial remain the CSS-wide keywords.
This commit is contained in:
Amruth Pillai
2026-09-29 18:50:00 +02:00
parent dc7e1e0431
commit 394e59e1af
20 changed files with 65 additions and 456 deletions
@@ -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", () => {
+2 -2
View File
@@ -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
+3 -12
View File
@@ -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({});
+2 -3
View File
@@ -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;
@@ -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" });
});
});
@@ -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([]);
});
});
@@ -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<typeof pdf>[0];
@@ -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);
});
});
-70
View File
@@ -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;
}
+2 -18
View File
@@ -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,
+18 -68
View File
@@ -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<Record<string, string | number>>,
parent: Readonly<Record<string, string | number>> | 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<string, string | number> = { ...base.style };
const specifiedStyleProperties = new Set<string>();
const hostBaseStyleProperties = new Set<string>();
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,
@@ -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.",
-1
View File
@@ -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";
@@ -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,
@@ -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) => {
@@ -21,8 +21,8 @@ export type PropertyDefinition = {
export type PropertyRegistry = Readonly<Record<string, PropertyDefinition | undefined>>;
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<Record<string, true | ((value: string) => 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));
}
@@ -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();
});
+6 -23
View File
@@ -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);
}
-1
View File
@@ -76,7 +76,6 @@ export type AuthoredPageContext = {
export type ResolvedNodeStyle = {
style: Readonly<Record<string, string | number>>;
specifiedStyleProperties?: readonly string[];
hostBaseStyleProperties?: readonly string[];
structural: StructuralPresentation;
hidden: boolean;
order: number;
+1 -3
View File
@@ -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;
}