@litfamily/litopencode 1.0.31 → 1.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/dist/activation-probe.js +1 -0
- package/dist/activation-prompt-utils.d.ts +1 -1
- package/dist/activation-reentry.d.ts +20 -0
- package/dist/activation-reentry.js +110 -1
- package/dist/activation-routing.d.ts +2 -1
- package/dist/activation-routing.js +100 -11
- package/dist/activation-workflow-prompts.d.ts +4 -0
- package/dist/activation-workflow-prompts.js +30 -0
- package/dist/activation.d.ts +1 -1
- package/dist/activation.js +44 -5
- package/dist/adhd-next.d.ts +51 -0
- package/dist/adhd-next.js +150 -0
- package/dist/cli/managed-skill-assets.d.ts +1 -1
- package/dist/cli/managed-skill-assets.js +7 -1
- package/dist/cli/native-skill-integrity.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/commands.d.ts +28 -1
- package/dist/commands.js +48 -1
- package/dist/features.d.ts +80 -1
- package/dist/features.js +105 -1
- package/dist/ledger.js +12 -1
- package/dist/secret-shapes.js +5 -5
- package/dist/skills.d.ts +22 -1
- package/dist/skills.js +36 -0
- package/dist/start-work-typed.d.ts +34 -0
- package/dist/start-work-typed.js +156 -0
- package/dist/state.d.ts +2 -0
- package/dist/state.js +2 -0
- package/dist/tools.js +22 -2
- package/dist/tui/mission-band.d.ts +20 -1
- package/dist/tui/mission-band.js +73 -16
- package/dist/tui/mission-looks.d.ts +38 -0
- package/dist/tui/mission-looks.js +73 -0
- package/dist/tui/mission-model.d.ts +2 -0
- package/dist/tui/mission-model.js +3 -1
- package/dist/tui/mission-rainbow.d.ts +33 -0
- package/dist/tui/mission-rainbow.js +90 -0
- package/dist/tui/mission-source.d.ts +48 -0
- package/dist/tui/mission-source.js +184 -2
- package/dist/tui/mission-text.d.ts +2 -0
- package/dist/tui/mission-text.js +9 -1
- package/dist/tui/mission-threads.d.ts +50 -0
- package/dist/tui/mission-threads.js +119 -0
- package/dist/tui.js +84 -6
- package/dist/uiux-visual-catalog.js +8 -1
- package/dist/v2/session-hooks.js +38 -5
- package/dist/workflow-stack.d.ts +53 -0
- package/dist/workflow-stack.js +196 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/assets/skills/lit-schematic.webp +0 -0
- package/docs/reference-Ko-KR.md +30 -2
- package/docs/reference.md +34 -2
- package/package.json +1 -1
- package/skills/frontend-ui-ux/SKILL.md +3 -2
- package/skills/frontend-ui-ux/kit/components.css +414 -0
- package/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
- package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
- package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
- package/skills/frontend-ui-ux/references/house-styles.md +236 -0
- package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
- package/skills/frontend-ui-ux/references/production.md +3 -1
- package/skills/frontend-ui-ux/references/slop-register.md +4 -4
- package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
- package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
- package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
- package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
- package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
- package/skills/lit-adhd/SKILL.md +208 -0
- package/skills/lit-adhd/references/brief-format.md +43 -0
- package/skills/lit-adhd/references/card-examples.md +82 -0
- package/skills/lit-adhd/references/status-taxonomy.md +29 -0
- package/skills/lit-adhd/scripts/rewind.mjs +835 -0
- package/skills/lit-diagram-drawer/SKILL.md +4 -3
- package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
- package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
- package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
- package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
- package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
- package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
- package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
- package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
- package/skills/lit-docx/SKILL.md +2 -1
- package/skills/lit-handoff/SKILL.md +2 -0
- package/skills/lit-hijack/SKILL.md +422 -0
- package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
- package/skills/lit-hijack/references/clean-room-build.md +111 -0
- package/skills/lit-hijack/references/distil-and-licence.md +100 -0
- package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
- package/skills/lit-hijack/references/parity-and-report.md +110 -0
- package/skills/lit-hijack/scripts/hijack.mjs +993 -0
- package/skills/lit-plan/SKILL.md +2 -0
- package/skills/lit-pptx/SKILL.md +27 -11
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
- package/skills/lit-pptx/references/density-and-fill.md +9 -3
- package/skills/lit-pptx/references/direction-step.md +21 -6
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
- package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/skills/lit-pptx/references/layout-families.md +65 -47
- package/skills/lit-pptx/references/title-treatments.md +83 -134
- package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
- package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
- package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
- package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
- package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
- package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
- package/skills/lit-pptx/references/tonalities/night.md +1 -1
- package/skills/lit-pptx/references/tonalities/paper.md +2 -2
- package/skills/lit-pptx/references/tonalities/riso.md +137 -0
- package/skills/lit-pptx/references/tonalities/signal.md +3 -3
- package/skills/lit-pptx/references/tonalities/studio.md +7 -6
- package/skills/lit-pptx/run.mjs +2 -1
- package/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
- package/skills/lit-pptx/scripts/craft_extras.py +56 -3
- package/skills/lit-pptx/scripts/deck_output.py +75 -90
- package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
- package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/skills/lit-pptx/scripts/qa_deck.py +10 -11
- package/skills/lit-pptx/scripts/render_pages.py +118 -0
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/skills/lit-recap/SKILL.md +4 -0
- package/skills/lit-schematic/SKILL.md +118 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
- package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
- package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
- package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
- package/skills/lit-schematic/references/isometric-craft.md +101 -0
- package/skills/lit-schematic/references/journal-figures.md +72 -0
- package/skills/lit-schematic/references/kit-api.md +134 -0
- package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
- package/skills/lit-schematic/references/pid-drafting.md +103 -0
- package/skills/lit-schematic/references/pid-symbols.md +163 -0
- package/skills/lit-schematic/references/verification.md +100 -0
- package/skills/lit-schematic/scripts/doctor.mjs +57 -0
- package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
- package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
- package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
- package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
- package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
- package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
- package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
- package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
- package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
- package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
- package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
- package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
- package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
- package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
- package/skills/lit-schematic/scripts/motion.mjs +129 -0
- package/skills/lit-schematic/scripts/qa.mjs +92 -0
- package/skills/lit-schematic/scripts/render.mjs +233 -0
- package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
- package/skills/lit-scientific-visualization/SKILL.md +27 -4
- package/skills/lit-typographic-motion/SKILL.md +19 -17
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
- package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
- package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
- package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
- package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
- package/skills/lit-typographic-motion/engine/board.mjs +138 -0
- package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
- package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
- package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
- package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
- package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
- package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
- package/skills/lit-typographic-motion/engine/house.mjs +42 -0
- package/skills/lit-typographic-motion/engine/look.mjs +65 -8
- package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
- package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
- package/skills/lit-typographic-motion/engine/score.mjs +594 -0
- package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
- package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
- package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
- package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
- package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
- package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
- package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
- package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
- package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
- package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
- package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
- package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
- package/skills/lit-typographic-motion/engine/text.mjs +1 -1
- package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
- package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/skills/lit-typographic-motion/gate.mjs +18 -2
- package/skills/lit-typographic-motion/probe.mjs +13 -1
- package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
- package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
- package/skills/lit-typographic-motion/references/cover.md +56 -13
- package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
- package/skills/lit-typographic-motion/references/depth.md +112 -0
- package/skills/lit-typographic-motion/references/runtime.md +21 -6
- package/skills/lit-typographic-motion/references/sound.md +76 -0
- package/skills/lit-typographic-motion/references/stage.md +50 -7
- package/skills/lit-typographic-motion/references/studio.md +162 -0
- package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
- package/skills/lit-typographic-motion/references/treatment.md +108 -9
- package/skills/lit-typographic-motion/render.mjs +60 -3
- package/skills/lit-typographic-motion/styles/paper.json +185 -0
- package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
- package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
- package/skills/lit-typographic-motion/styles/tidal.json +187 -0
- package/skills/litresearch/SKILL.md +2 -0
- package/skills/managed-skill-manifest.json +517 -88
- package/skills/readme-studio/SKILL.md +1 -1
- package/skills/review-work/SKILL.md +2 -0
- package/skills/start-work/SKILL.md +8 -0
- package/skills/workflow-loop/SKILL.md +4 -0
- package/tools/check-pack-payload.mjs +4 -2
- package/tools/payload-substance-parity.json +4 -3
- package/tools/qa-cover-recipes.mjs +4 -3
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SVG string building for lit-schematic. Every drawing is authored in millimetres: the
|
|
2
|
+
// viewBox unit is 1 mm at final print size, so stroke widths and font sizes are physical
|
|
3
|
+
// and the QA gate can check them without knowing the page.
|
|
4
|
+
|
|
5
|
+
export const MM_PER_PT = 25.4 / 72;
|
|
6
|
+
export const PT_PER_MM = 72 / 25.4;
|
|
7
|
+
export const ptToMm = (pt) => pt * MM_PER_PT;
|
|
8
|
+
export const mmToPt = (mm) => mm * PT_PER_MM;
|
|
9
|
+
|
|
10
|
+
export function n(value) {
|
|
11
|
+
if (!Number.isFinite(value)) throw new Error(`lit-schematic: non-finite coordinate ${value}`);
|
|
12
|
+
const text = (Math.round(value * 100) / 100).toFixed(2).replace(/\.?0+$/u, "");
|
|
13
|
+
return text === "-0" ? "0" : text;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const esc = (text) => String(text)
|
|
17
|
+
.replaceAll("&", "&")
|
|
18
|
+
.replaceAll("<", "<")
|
|
19
|
+
.replaceAll(">", ">")
|
|
20
|
+
.replaceAll('"', """);
|
|
21
|
+
|
|
22
|
+
export function attrs(pairs = {}) {
|
|
23
|
+
return Object.entries(pairs)
|
|
24
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== false)
|
|
25
|
+
.map(([key, value]) => ` ${key}="${esc(typeof value === "number" ? n(value) : String(value))}"`)
|
|
26
|
+
.join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const el = (tag, pairs, ...children) => {
|
|
30
|
+
const body = children.flat().filter((child) => child !== undefined && child !== null && child !== false).join("");
|
|
31
|
+
return body ? `<${tag}${attrs(pairs)}>${body}</${tag}>` : `<${tag}${attrs(pairs)}/>`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function pathOf(points, closed = false) {
|
|
35
|
+
if (!points.length) return "";
|
|
36
|
+
return points.map(([x, y], index) => `${index ? "L" : "M"}${n(x)} ${n(y)}`).join("") + (closed ? "Z" : "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const hasHangul = (text) => /[ᄀ-ᇿ-가-]/u.test(String(text));
|
|
40
|
+
|
|
41
|
+
// A conservative width estimate in em units, used for label placement and by the QA gate.
|
|
42
|
+
// Arial-like Latin and Pretendard Hangul; it overestimates slightly so a pass is safe.
|
|
43
|
+
export function textWidthEm(text) {
|
|
44
|
+
let units = 0;
|
|
45
|
+
for (const ch of String(text)) {
|
|
46
|
+
if (/\s/u.test(ch)) units += 0.28;
|
|
47
|
+
else if (hasHangul(ch)) units += 0.96;
|
|
48
|
+
else if (/[ilIj.,:;!|'`]/u.test(ch)) units += 0.3;
|
|
49
|
+
else if (/[ftr()[\]{}/\\-]/u.test(ch)) units += 0.38;
|
|
50
|
+
else if (/[MWmw@%]/u.test(ch)) units += 0.86;
|
|
51
|
+
else if (/[A-Z0-9#&]/u.test(ch)) units += 0.68;
|
|
52
|
+
else units += 0.56;
|
|
53
|
+
}
|
|
54
|
+
return units;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Box of a single-line label: [x0, y0, x1, y1] for a baseline at y with the given anchor.
|
|
58
|
+
export function textBox(x, y, text, size, anchor = "start") {
|
|
59
|
+
const width = textWidthEm(text) * size;
|
|
60
|
+
const left = anchor === "middle" ? x - width / 2 : anchor === "end" ? x - width : x;
|
|
61
|
+
return [left, y - size * 0.8, left + width, y + size * 0.22];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function svgDocument({ width, height, title, desc = "", mode, lang = "en", body, defs = "", style = "", extra = {} }) {
|
|
65
|
+
if (!title) throw new Error("lit-schematic: a drawing needs a title (accessible name)");
|
|
66
|
+
const head = [
|
|
67
|
+
`<title id="schematic-title">${esc(title)}</title>`,
|
|
68
|
+
desc ? `<desc id="schematic-desc">${esc(desc)}</desc>` : "",
|
|
69
|
+
style ? `<style>${style}</style>` : "",
|
|
70
|
+
defs ? `<defs>${defs}</defs>` : "",
|
|
71
|
+
].join("");
|
|
72
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg"${attrs({
|
|
73
|
+
width: `${n(width)}mm`,
|
|
74
|
+
height: `${n(height)}mm`,
|
|
75
|
+
viewBox: `0 0 ${n(width)} ${n(height)}`,
|
|
76
|
+
role: "img",
|
|
77
|
+
"aria-labelledby": desc ? "schematic-title schematic-desc" : "schematic-title",
|
|
78
|
+
"xml:lang": lang,
|
|
79
|
+
"data-schematic-mode": mode,
|
|
80
|
+
...extra,
|
|
81
|
+
})}>${head}${body}</svg>\n`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { hasHangul, ptToMm } from "./svg.mjs";
|
|
2
|
+
|
|
3
|
+
// Latin text defaults to a TrueType face because Chrome embeds TrueType in a PDF as real text
|
|
4
|
+
// (CID TrueType), while CFF and variable fonts such as Pretendard become Type 3 glyphs, which
|
|
5
|
+
// some journal checkers reject. Hangul labels use Pretendard for every glyph of the label.
|
|
6
|
+
export const FONT_LATIN = "Arial, Helvetica, 'Liberation Sans', sans-serif";
|
|
7
|
+
export const FONT_KOREAN = "Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif";
|
|
8
|
+
export const fontFor = (text) => (hasHangul(text) ? FONT_KOREAN : FONT_LATIN);
|
|
9
|
+
|
|
10
|
+
// P&ID ink on an ISO A-series sheet drawn at 1:1, in millimetres. Line weight is the tone
|
|
11
|
+
// hierarchy of a P&ID: equipment and main process lines carry the sheet, signals stay light.
|
|
12
|
+
// Pipe classes step down the ISO 128 width series (0.7, 0.5, 0.35, 0.18) so a main process
|
|
13
|
+
// line, a minor process line, a utility line and an instrument connection never look alike.
|
|
14
|
+
export const PID = Object.freeze({
|
|
15
|
+
ink: "#000000",
|
|
16
|
+
paper: "#ffffff",
|
|
17
|
+
weight: Object.freeze({
|
|
18
|
+
frame: 0.7,
|
|
19
|
+
equipment: 0.5,
|
|
20
|
+
major: 0.7,
|
|
21
|
+
minor: 0.5,
|
|
22
|
+
utility: 0.35,
|
|
23
|
+
symbol: 0.35,
|
|
24
|
+
signal: 0.25,
|
|
25
|
+
connection: 0.18,
|
|
26
|
+
leader: 0.18,
|
|
27
|
+
grid: 0.18,
|
|
28
|
+
detail: 0.18,
|
|
29
|
+
}),
|
|
30
|
+
text: Object.freeze({ tag: 2.5, label: 2.5, line: 2.2, equipment: 3.0, note: 2.5, title: 5, small: 2.2 }),
|
|
31
|
+
minTextMm: 2.0,
|
|
32
|
+
minStrokeMm: 0.13,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Okabe–Ito: eight hues that stay distinct for the common colour-vision deficiencies.
|
|
36
|
+
export const OKABE_ITO = Object.freeze({
|
|
37
|
+
black: "#000000",
|
|
38
|
+
orange: "#E69F00",
|
|
39
|
+
sky: "#56B4E9",
|
|
40
|
+
green: "#009E73",
|
|
41
|
+
yellow: "#F0E442",
|
|
42
|
+
blue: "#0072B2",
|
|
43
|
+
vermillion: "#D55E00",
|
|
44
|
+
purple: "#CC79A7",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const GREYS = Object.freeze(["#000000", "#333333", "#555555", "#777777", "#999999", "#BBBBBB", "#DDDDDD", "#F2F2F2", "#FFFFFF"]);
|
|
48
|
+
|
|
49
|
+
// Stream roles for apparatus figures. A role keeps one colour across every panel of a figure,
|
|
50
|
+
// and every role is distinguishable under deuteranopia and protanopia (checked by PALETTE.CVD).
|
|
51
|
+
export const STREAM = Object.freeze({
|
|
52
|
+
gas: Object.freeze({ color: OKABE_ITO.blue, dash: null, label: "Gas" }),
|
|
53
|
+
liquid: Object.freeze({ color: OKABE_ITO.sky, dash: null, label: "Liquid" }),
|
|
54
|
+
coolant: Object.freeze({ color: OKABE_ITO.green, dash: null, label: "Coolant" }),
|
|
55
|
+
heating: Object.freeze({ color: OKABE_ITO.vermillion, dash: null, label: "Heating medium" }),
|
|
56
|
+
vent: Object.freeze({ color: "#777777", dash: "1.2 0.8", label: "Vent" }),
|
|
57
|
+
signal: Object.freeze({ color: "#555555", dash: "0.9 0.6", label: "Signal" }),
|
|
58
|
+
sample: Object.freeze({ color: OKABE_ITO.purple, dash: null, label: "Sample" }),
|
|
59
|
+
vacuum: Object.freeze({ color: OKABE_ITO.orange, dash: "2 0.8", label: "Vacuum" }),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Journal figures: type and line sizes at final print size.
|
|
63
|
+
export const JOURNAL = Object.freeze({
|
|
64
|
+
width: Object.freeze({ single: 85, onehalf: 120, double: 178 }),
|
|
65
|
+
maxHeight: 230,
|
|
66
|
+
textPt: 7,
|
|
67
|
+
minTextPt: 6,
|
|
68
|
+
panelPt: 8,
|
|
69
|
+
linePt: 0.75,
|
|
70
|
+
minLinePt: 0.35,
|
|
71
|
+
get text() { return ptToMm(this.textPt); },
|
|
72
|
+
get panel() { return ptToMm(this.panelPt); },
|
|
73
|
+
get line() { return ptToMm(this.linePt); },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Isometric illustration tokens, light paper. These are the grey palette's shades (the opt-in
|
|
77
|
+
// quiet look) and the strokes, outlines and halo shared by every palette.
|
|
78
|
+
export const ISO = Object.freeze({
|
|
79
|
+
paper: "#ffffff",
|
|
80
|
+
face: "#ffffff",
|
|
81
|
+
top: "#fbfbf9",
|
|
82
|
+
shade: Object.freeze(["#d6d6d2", "#e1e1dd", "#eaeae6", "#f3f3f0"]),
|
|
83
|
+
section: "#c9c9c4",
|
|
84
|
+
cavity: Object.freeze(["#b9bcbf", "#c6c9cc", "#d2d4d6", "#dcdee0"]),
|
|
85
|
+
hi: "#222226",
|
|
86
|
+
mid: "#6f6f75",
|
|
87
|
+
lo: "#a9a9ae",
|
|
88
|
+
faint: "#d0d0d3",
|
|
89
|
+
lit: "#000000",
|
|
90
|
+
accent: OKABE_ITO.blue,
|
|
91
|
+
halo: "#7d7d74",
|
|
92
|
+
haloOpacity: 0.28,
|
|
93
|
+
liquid: "#cfe3ee",
|
|
94
|
+
liquidEdge: "#8fb6cc",
|
|
95
|
+
gas: "#eef4f8",
|
|
96
|
+
weight: 0.2,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Material palette, the isometric default: each base is the mid tone of a real finish, and the
|
|
100
|
+
// view derives a light-to-shadow ramp from it. Colour names what a part is made of; the accent
|
|
101
|
+
// is signal red, a hue no common finish uses, for the one part the text is about.
|
|
102
|
+
export const MATERIAL = Object.freeze({
|
|
103
|
+
concrete: "#b9b2a3",
|
|
104
|
+
steel: "#4f86b5",
|
|
105
|
+
stainless: "#aab6c2",
|
|
106
|
+
frame: "#5f8f4e",
|
|
107
|
+
copper: "#c8743a",
|
|
108
|
+
brass: "#c9a227",
|
|
109
|
+
cast: "#4a4f57",
|
|
110
|
+
insulation: "#d3d7d4",
|
|
111
|
+
rubber: "#2f3133",
|
|
112
|
+
glass: "#bfe0e8",
|
|
113
|
+
accent: "#d1493f",
|
|
114
|
+
liquid: "#8cc3e0",
|
|
115
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Turns a drawing that moves into a short loop: MP4 for slides and players, an animated WebP
|
|
3
|
+
// and a GIF for READMEs and chat.
|
|
4
|
+
//
|
|
5
|
+
// node motion.mjs <scene.mjs> --out <dir> [--name loop] [--seconds 4] [--fps 24] [--width 1280]
|
|
6
|
+
// [--small 720] [--formats mp4,webp,gif]
|
|
7
|
+
//
|
|
8
|
+
// <scene.mjs> exports frame(t) -> SVG string for t in [0, 1); frame(0) must equal the frame
|
|
9
|
+
// after the last one, so the loop closes without a jump. Every frame is drawn by the kit and
|
|
10
|
+
// rasterised by headless Chrome, so the film is deterministic: the same scene gives the same
|
|
11
|
+
// frames. Encoding uses ffmpeg (MP4, GIF) and img2webp (WebP). Nothing is installed: a missing
|
|
12
|
+
// tool stops the run with ENCODER_REQUIRED and the line for the user to run.
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { findChrome, renderPng, svgSize } from "./render.mjs";
|
|
20
|
+
|
|
21
|
+
export function findTool(name, env = process.env) {
|
|
22
|
+
const dirs = (env.PATH ?? "").split(process.platform === "win32" ? ";" : ":").filter(Boolean);
|
|
23
|
+
for (const dir of [...dirs, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]) {
|
|
24
|
+
const file = join(dir, process.platform === "win32" ? `${name}.exe` : name);
|
|
25
|
+
if (existsSync(file)) return file;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const INSTALL = {
|
|
31
|
+
ffmpeg: "macOS: brew install ffmpeg · Debian/Ubuntu: sudo apt install ffmpeg · Windows: winget install ffmpeg",
|
|
32
|
+
img2webp: "macOS: brew install webp · Debian/Ubuntu: sudo apt install webp · Windows: winget install Google.Libwebp",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function run(bin, args) {
|
|
36
|
+
const result = spawnSync(bin, args, { encoding: "utf8", timeout: 600_000 });
|
|
37
|
+
if (result.error) throw new Error(`ENCODE_FAILED ${bin}: ${result.error.message}`);
|
|
38
|
+
if (result.status !== 0) throw new Error(`ENCODE_FAILED ${bin} exited ${result.status}: ${(result.stderr || "").split("\n").filter(Boolean).slice(-2).join(" ")}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderMotion({ frame, out, name = "loop", seconds = 4, fps = 24, width = 1280, small = 720, formats = ["mp4", "webp", "gif"], onFrame = null, which = findTool, chromePath = findChrome() }) {
|
|
42
|
+
const chrome = chromePath;
|
|
43
|
+
if (!chrome) throw new Error("RENDERER_REQUIRED no Chrome or Chromium found. Install Google Chrome or set CHROME_PATH, then retry.");
|
|
44
|
+
const need = new Set(formats.flatMap((f) => (f === "webp" ? ["ffmpeg", "img2webp"] : ["ffmpeg"])));
|
|
45
|
+
const tools = {};
|
|
46
|
+
for (const tool of need) {
|
|
47
|
+
tools[tool] = which(tool);
|
|
48
|
+
if (!tools[tool]) throw new Error(`ENCODER_REQUIRED ${tool} not found. This script never installs software. ${INSTALL[tool]}`);
|
|
49
|
+
}
|
|
50
|
+
mkdirSync(out, { recursive: true });
|
|
51
|
+
const work = mkdtempSync(join(tmpdir(), "lit-schematic-motion-"));
|
|
52
|
+
try {
|
|
53
|
+
const count = Math.round(seconds * fps);
|
|
54
|
+
const frames = join(work, "frames");
|
|
55
|
+
mkdirSync(frames);
|
|
56
|
+
let size = null;
|
|
57
|
+
for (let i = 0; i < count; i++) {
|
|
58
|
+
const svg = frame(i / count);
|
|
59
|
+
size ??= svgSize(svg);
|
|
60
|
+
const dpi = (width / size.width) * 25.4;
|
|
61
|
+
renderPng({ chrome, svg, out: join(frames, `f${String(i).padStart(4, "0")}.png`), dpi, verify: false });
|
|
62
|
+
onFrame?.(i + 1, count);
|
|
63
|
+
}
|
|
64
|
+
const even = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
|
|
65
|
+
const results = {};
|
|
66
|
+
const input = ["-y", "-loglevel", "error", "-framerate", String(fps), "-i", join(frames, "f%04d.png")];
|
|
67
|
+
if (formats.includes("mp4")) {
|
|
68
|
+
const file = join(out, `${name}.mp4`);
|
|
69
|
+
run(tools.ffmpeg, [...input, "-vf", `${even},format=yuv420p`, "-c:v", "libx264", "-crf", "18", "-preset", "slow", "-movflags", "+faststart", file]);
|
|
70
|
+
results.mp4 = file;
|
|
71
|
+
}
|
|
72
|
+
if (formats.includes("gif")) {
|
|
73
|
+
const file = join(out, `${name}.gif`);
|
|
74
|
+
run(tools.ffmpeg, [...input, "-vf", `scale=${small}:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4`, "-loop", "0", file]);
|
|
75
|
+
results.gif = file;
|
|
76
|
+
}
|
|
77
|
+
if (formats.includes("webp")) {
|
|
78
|
+
const smallDir = join(work, "small");
|
|
79
|
+
mkdirSync(smallDir);
|
|
80
|
+
run(tools.ffmpeg, [...input, "-vf", `scale=${small}:-1:flags=lanczos`, join(smallDir, "s%04d.png")]);
|
|
81
|
+
const file = join(out, `${name}.webp`);
|
|
82
|
+
const delay = String(Math.round(1000 / fps));
|
|
83
|
+
const list = readdirSync(smallDir).filter((f) => f.endsWith(".png")).sort();
|
|
84
|
+
run(tools.img2webp, ["-loop", "0", "-lossy", "-q", "78", "-m", "4", ...list.flatMap((f) => ["-d", delay, join(smallDir, f)]), "-o", file]);
|
|
85
|
+
results.webp = file;
|
|
86
|
+
}
|
|
87
|
+
return { frames: count, fps, seconds, width, files: Object.fromEntries(Object.entries(results).map(([k, f]) => [k, { file: f, bytes: statSync(f).size }])) };
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(work, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function main(argv) {
|
|
94
|
+
const args = argv.slice(2);
|
|
95
|
+
const value = (flag, fallback) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : fallback; };
|
|
96
|
+
const scene = args[0];
|
|
97
|
+
if (!scene || scene.startsWith("--") || !value("--out")) {
|
|
98
|
+
console.error("Usage: node motion.mjs <scene.mjs exporting frame(t)> --out <dir> [--name loop] [--seconds 4] [--fps 24] [--width 1280] [--small 720] [--formats mp4,webp,gif]");
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
const mod = await import(pathToFileURL(resolve(scene)).href);
|
|
102
|
+
if (typeof mod.frame !== "function") {
|
|
103
|
+
console.error(`MOTION_SCENE ${scene} must export frame(t) returning an SVG string`);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const result = renderMotion({
|
|
108
|
+
frame: mod.frame,
|
|
109
|
+
out: resolve(value("--out")),
|
|
110
|
+
name: value("--name", "loop"),
|
|
111
|
+
seconds: Number(value("--seconds", 4)),
|
|
112
|
+
fps: Number(value("--fps", 24)),
|
|
113
|
+
width: Number(value("--width", 1280)),
|
|
114
|
+
small: Number(value("--small", 720)),
|
|
115
|
+
formats: value("--formats", "mp4,webp,gif").split(","),
|
|
116
|
+
onFrame: (i, n) => { if (i === n || i % 24 === 0) console.error(`frame ${i}/${n}`); },
|
|
117
|
+
});
|
|
118
|
+
for (const [kind, { file, bytes }] of Object.entries(result.files)) console.log(`MOTION_${kind.toUpperCase()} ${file} ${(bytes / 1024).toFixed(0)} KB`);
|
|
119
|
+
console.log(`MOTION_DONE ${result.frames} frames at ${result.fps} fps (${result.seconds} s)`);
|
|
120
|
+
return 0;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error(error.message);
|
|
123
|
+
return /REQUIRED/u.test(error.message) ? 2 : 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
128
|
+
main(process.argv).then((code) => { process.exitCode = code; });
|
|
129
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The lit-schematic QA gate.
|
|
3
|
+
//
|
|
4
|
+
// node qa.mjs drawing.svg static checks; exit 1 on any FAIL
|
|
5
|
+
// node qa.mjs drawing.svg --json the same result as JSON
|
|
6
|
+
// node qa.mjs drawing.svg --png d.png --pdf d.pdf [--dpi 600]
|
|
7
|
+
// also checks the exports are whole, the PNG
|
|
8
|
+
// says its resolution and the PDF has the SVG's size
|
|
9
|
+
// node qa.mjs --codes list every check code and what it means
|
|
10
|
+
//
|
|
11
|
+
// Options: --mode pid|pfd|journal|iso|figure (default: the root's data-schematic-mode),
|
|
12
|
+
// --min-pt <n> minimum text size at print, --min-stroke-mm <n>.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import { CODES, check, formatReport } from "./lib/qa-core.mjs";
|
|
18
|
+
import { pngInfo } from "./lib/png.mjs";
|
|
19
|
+
import { svgSize } from "./render.mjs";
|
|
20
|
+
|
|
21
|
+
export function checkExports({ svg, png, pdf, dpi = 600 }) {
|
|
22
|
+
const findings = [];
|
|
23
|
+
const add = (code, message) => findings.push({ code, level: "FAIL", message });
|
|
24
|
+
const size = svgSize(svg);
|
|
25
|
+
if (png) {
|
|
26
|
+
try {
|
|
27
|
+
const info = pngInfo(readFileSync(png));
|
|
28
|
+
if (!info.closed) add("PNG.CLOSED", `${png} is truncated or has a bad chunk checksum`);
|
|
29
|
+
if (!info.dpi || info.dpi + 0.5 < dpi) add("PNG.DPI", `${png} declares ${info.dpi ?? "no"} dpi; ${dpi} dpi is required`);
|
|
30
|
+
const expected = Math.round((size.width / 25.4) * (info.dpi ?? dpi));
|
|
31
|
+
if (info.dpi && Math.abs(info.width - expected) > Math.max(3, expected * 0.01)) add("PNG.DPI", `${png} is ${info.width} px wide; ${expected} px is the drawing width at ${info.dpi} dpi`);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
add("PNG.CLOSED", `${png}: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (pdf) {
|
|
37
|
+
try {
|
|
38
|
+
const bytes = readFileSync(pdf);
|
|
39
|
+
const head = bytes.subarray(0, 5).toString("latin1");
|
|
40
|
+
const tail = bytes.subarray(Math.max(0, bytes.length - 1024)).toString("latin1");
|
|
41
|
+
if (head !== "%PDF-" || !tail.includes("%%EOF")) add("PDF.CLOSED", `${pdf} lacks a PDF header or end-of-file marker`);
|
|
42
|
+
const box = /\/MediaBox\s*\[\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*\]/u.exec(bytes.toString("latin1"));
|
|
43
|
+
if (!box) add("PDF.SIZE", `${pdf} has no readable MediaBox`);
|
|
44
|
+
else {
|
|
45
|
+
const w = ((Number(box[3]) - Number(box[1])) * 25.4) / 72;
|
|
46
|
+
const h = ((Number(box[4]) - Number(box[2])) * 25.4) / 72;
|
|
47
|
+
if (Math.abs(w - size.width) > 1 || Math.abs(h - size.height) > 1) add("PDF.SIZE", `${pdf} page is ${w.toFixed(1)} x ${h.toFixed(1)} mm; the drawing is ${size.width.toFixed(1)} x ${size.height.toFixed(1)} mm`);
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
add("PDF.CLOSED", `${pdf}: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return findings;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function main(argv) {
|
|
57
|
+
const args = argv.slice(2);
|
|
58
|
+
const value = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
|
|
59
|
+
if (args.includes("--codes")) {
|
|
60
|
+
for (const [code, meaning] of Object.entries(CODES)) console.log(`${code.padEnd(20)} ${meaning}`);
|
|
61
|
+
console.log(`${"PNG.CLOSED".padEnd(20)} The PNG is truncated or a chunk checksum is wrong.`);
|
|
62
|
+
console.log(`${"PNG.DPI".padEnd(20)} The PNG does not declare the required resolution or is the wrong pixel width.`);
|
|
63
|
+
console.log(`${"PDF.CLOSED".padEnd(20)} The PDF lacks its header or end-of-file marker.`);
|
|
64
|
+
console.log(`${"PDF.SIZE".padEnd(20)} The PDF page size differs from the drawing size.`);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
const flagged = new Set(["--mode", "--min-pt", "--min-stroke-mm", "--png", "--pdf", "--dpi"]);
|
|
68
|
+
const input = args.find((a, i) => !a.startsWith("--") && !flagged.has(args[i - 1]));
|
|
69
|
+
if (!input) {
|
|
70
|
+
console.error("Usage: node qa.mjs drawing.svg [--json] [--mode pid|pfd|journal|iso|figure] [--min-pt 6] [--png file --pdf file --dpi 600] | --codes");
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
const svg = readFileSync(resolve(input), "utf8");
|
|
74
|
+
const result = check(svg, {
|
|
75
|
+
mode: value("--mode"),
|
|
76
|
+
minTextPt: value("--min-pt") ? Number(value("--min-pt")) : undefined,
|
|
77
|
+
minStrokeMm: value("--min-stroke-mm") ? Number(value("--min-stroke-mm")) : undefined,
|
|
78
|
+
});
|
|
79
|
+
if (value("--png") || value("--pdf")) {
|
|
80
|
+
const extra = checkExports({ svg, png: value("--png") && resolve(value("--png")), pdf: value("--pdf") && resolve(value("--pdf")), dpi: Number(value("--dpi") ?? 600) });
|
|
81
|
+
result.findings.push(...extra);
|
|
82
|
+
result.fails += extra.length;
|
|
83
|
+
if (extra.length) result.verdict = "FAIL";
|
|
84
|
+
}
|
|
85
|
+
if (args.includes("--json")) console.log(JSON.stringify({ file: input, ...result }, null, 2));
|
|
86
|
+
else console.log(formatReport(result, input));
|
|
87
|
+
return result.verdict === "PASS" ? 0 : 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
91
|
+
process.exitCode = main(process.argv);
|
|
92
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Renders a lit-schematic SVG through headless Chrome.
|
|
3
|
+
//
|
|
4
|
+
// node render.mjs drawing.svg --png drawing.png [--dpi 600] raster at print size, pHYs written
|
|
5
|
+
// node render.mjs drawing.svg --pdf drawing.pdf vector PDF at the SVG's mm size
|
|
6
|
+
// node render.mjs drawing.svg --crop x,y,w,h --png close.png one region (viewBox mm) at --dpi
|
|
7
|
+
// node render.mjs drawing.svg --sheet sweep.png [--grid 3x2] close-up contact sheet of the whole drawing
|
|
8
|
+
//
|
|
9
|
+
// It never installs anything. Without Chrome it exits 2 with RENDERER_REQUIRED and the setup
|
|
10
|
+
// line for the user to run. Every launch uses a throwaway profile, a mock keychain and a basic
|
|
11
|
+
// password store, so no keychain dialog reaches the desktop, and the profile is removed after.
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { basename, join, resolve } from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
import { findPretendard } from "./lib/fonts.mjs";
|
|
19
|
+
import { inkGrid, missingInk, pngInfo, withDpi } from "./lib/png.mjs";
|
|
20
|
+
|
|
21
|
+
export const CHROME_FLAGS = Object.freeze([
|
|
22
|
+
"--headless=new", "--use-mock-keychain", "--password-store=basic", "--no-first-run",
|
|
23
|
+
"--no-default-browser-check", "--disable-gpu", "--hide-scrollbars", "--disable-extensions",
|
|
24
|
+
]);
|
|
25
|
+
const PX_PER_MM = 96 / 25.4;
|
|
26
|
+
const MAX_SIDE = 16000;
|
|
27
|
+
// Headless Chrome returns a screenshot larger than about 100 million pixels with only its first
|
|
28
|
+
// tiles painted and no error (an A2 sheet at 600 dpi is 139 Mpx). Stay well under that.
|
|
29
|
+
const MAX_PIXELS = 80e6;
|
|
30
|
+
|
|
31
|
+
// The device scale and the dpi actually used for a page of cssW x cssH CSS pixels.
|
|
32
|
+
export function rasterScale(cssW, cssH, dpi) {
|
|
33
|
+
let scale = dpi / 96;
|
|
34
|
+
if (Math.max(cssW, cssH) * scale > MAX_SIDE) scale = MAX_SIDE / Math.max(cssW, cssH);
|
|
35
|
+
if (cssW * cssH * scale * scale > MAX_PIXELS) scale = Math.sqrt(MAX_PIXELS / (cssW * cssH));
|
|
36
|
+
const used = Math.min(dpi, Math.floor(scale * 96));
|
|
37
|
+
return { scale: used / 96, dpi: used };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function findChrome(env = process.env) {
|
|
41
|
+
const candidates = [
|
|
42
|
+
env.CHROME_PATH,
|
|
43
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
44
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
45
|
+
"/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
46
|
+
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
|
|
47
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
48
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
49
|
+
].filter(Boolean);
|
|
50
|
+
return candidates.find((file) => existsSync(file)) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function svgSize(svg) {
|
|
54
|
+
const root = svg.match(/<svg\b[^>]*>/u)?.[0];
|
|
55
|
+
if (!root) throw new Error("SVG_MISSING no <svg> root");
|
|
56
|
+
const get = (name) => root.match(new RegExp(`\\s${name}="([^"]+)"`, "u"))?.[1];
|
|
57
|
+
const view = get("viewBox")?.trim().split(/[\s,]+/u).map(Number);
|
|
58
|
+
const toMm = (value) => {
|
|
59
|
+
const m = /^([\d.]+)\s*(mm|cm|in|pt|px)?$/u.exec(value ?? "");
|
|
60
|
+
if (!m) return null;
|
|
61
|
+
const v = Number(m[1]);
|
|
62
|
+
return { mm: v, cm: v * 10, in: v * 25.4, pt: (v * 25.4) / 72, px: v / PX_PER_MM, undefined: v / PX_PER_MM }[m[2]];
|
|
63
|
+
};
|
|
64
|
+
const width = toMm(get("width"));
|
|
65
|
+
const height = toMm(get("height"));
|
|
66
|
+
if (!view || view.length !== 4 || !width || !height) throw new Error("SVG_SIZE the root needs a viewBox and a physical width and height (for example width=\"178mm\")");
|
|
67
|
+
return { width, height, view };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fontFace() {
|
|
71
|
+
const font = findPretendard();
|
|
72
|
+
if (!font) return "";
|
|
73
|
+
return `@font-face{font-family:Pretendard;src:url("${pathToFileURL(font.file).href}");font-weight:100 900}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const stripProlog = (svg) => svg.replace(/^\s*<\?xml[^>]*>\s*/u, "");
|
|
77
|
+
|
|
78
|
+
function page({ svg, widthMm, heightMm, body }) {
|
|
79
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace()}@page{size:${widthMm}mm ${heightMm}mm;margin:0}html,body{margin:0;padding:0;background:#ffffff}svg{display:block}</style></head><body>${body ?? stripProlog(svg)}</body></html>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function runChrome(chrome, args, html, work) {
|
|
83
|
+
const file = join(work, "page.html");
|
|
84
|
+
writeFileSync(file, html);
|
|
85
|
+
const profile = mkdtempSync(join(work, "profile-"));
|
|
86
|
+
try {
|
|
87
|
+
const result = spawnSync(chrome, [...CHROME_FLAGS, `--user-data-dir=${profile}`, ...args, pathToFileURL(file).href], { encoding: "utf8", timeout: 180_000 });
|
|
88
|
+
if (result.error) throw new Error(`RENDER_FAILED ${result.error.message}`);
|
|
89
|
+
if (result.status !== 0) throw new Error(`RENDER_FAILED chrome exited ${result.status}: ${(result.stderr || "").split("\n").slice(-3).join(" ")}`);
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(profile, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// `verify: false` skips the reference render, for film frames far below the size that can fail.
|
|
96
|
+
export function renderPng({ chrome, svg, out, dpi = 600, crop = null, verify = true }) {
|
|
97
|
+
const size = svgSize(svg);
|
|
98
|
+
let source = svg;
|
|
99
|
+
let widthMm = size.width;
|
|
100
|
+
let heightMm = size.height;
|
|
101
|
+
if (crop) {
|
|
102
|
+
const [x, y, w, h] = crop;
|
|
103
|
+
const sx = size.width / size.view[2];
|
|
104
|
+
widthMm = w * sx;
|
|
105
|
+
heightMm = h * sx;
|
|
106
|
+
source = svg.replace(/<svg\b[^>]*>/u, (root) => root
|
|
107
|
+
.replace(/\sviewBox="[^"]*"/u, ` viewBox="${x} ${y} ${w} ${h}"`)
|
|
108
|
+
.replace(/\swidth="[^"]*"/u, ` width="${widthMm}mm"`)
|
|
109
|
+
.replace(/\sheight="[^"]*"/u, ` height="${heightMm}mm"`));
|
|
110
|
+
}
|
|
111
|
+
const cssW = Math.ceil(widthMm * PX_PER_MM);
|
|
112
|
+
const cssH = Math.ceil(heightMm * PX_PER_MM);
|
|
113
|
+
const work = mkdtempSync(join(tmpdir(), "lit-schematic-render-"));
|
|
114
|
+
try {
|
|
115
|
+
const raw = join(work, "raw.png");
|
|
116
|
+
const html = page({ svg: source, widthMm, heightMm });
|
|
117
|
+
// A small reference render shows where ink belongs; a cell inked there and blank in the full
|
|
118
|
+
// render means the browser stopped painting part of the page. That limit depends on the
|
|
119
|
+
// drawing as well as the size, so step the dpi down until the whole page arrives.
|
|
120
|
+
const ref = join(work, "ref.png");
|
|
121
|
+
if (verify) runChrome(chrome, ["--force-device-scale-factor=0.5", `--window-size=${cssW},${cssH}`, `--screenshot=${ref}`], html, work);
|
|
122
|
+
const reference = verify ? inkGrid(readFileSync(ref)) : null;
|
|
123
|
+
let { dpi: used } = rasterScale(cssW, cssH, dpi);
|
|
124
|
+
for (let attempt = 0; ; attempt++) {
|
|
125
|
+
runChrome(chrome, [`--force-device-scale-factor=${used / 96}`, `--window-size=${cssW},${cssH}`, `--screenshot=${raw}`], html, work);
|
|
126
|
+
const blank = verify ? missingInk(reference, inkGrid(readFileSync(raw))) : [];
|
|
127
|
+
if (!blank.length) break;
|
|
128
|
+
if (attempt === 3) throw new Error(`RENDER_INCOMPLETE ${blank.length} of 36 regions came out blank at ${used} dpi though they hold ink; render --crop regions instead`);
|
|
129
|
+
used = Math.floor(used * 0.85);
|
|
130
|
+
}
|
|
131
|
+
const png = withDpi(readFileSync(raw), used);
|
|
132
|
+
writeFileSync(out, png);
|
|
133
|
+
return { out, ...pngInfo(png), requestedDpi: dpi };
|
|
134
|
+
} finally {
|
|
135
|
+
rmSync(work, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function renderPdf({ chrome, svg, out }) {
|
|
140
|
+
const size = svgSize(svg);
|
|
141
|
+
const work = mkdtempSync(join(tmpdir(), "lit-schematic-render-"));
|
|
142
|
+
try {
|
|
143
|
+
runChrome(chrome, ["--no-pdf-header-footer", `--print-to-pdf=${resolve(out)}`], page({ svg, widthMm: size.width, heightMm: size.height }), work);
|
|
144
|
+
return { out, widthMm: size.width, heightMm: size.height };
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(work, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// One image of every region of the drawing enlarged, labelled with its viewBox box, so the
|
|
151
|
+
// close-up sweep is a single file to read.
|
|
152
|
+
export function renderSheet({ chrome, svg, out, grid = [3, 2], cellPx = 640 }) {
|
|
153
|
+
const size = svgSize(svg);
|
|
154
|
+
const [vx, vy, vw, vh] = size.view;
|
|
155
|
+
const [cols, rows] = grid;
|
|
156
|
+
const cw = vw / cols;
|
|
157
|
+
const ch = vh / rows;
|
|
158
|
+
const cellH = Math.round((cellPx * ch) / cw);
|
|
159
|
+
const inner = stripProlog(svg);
|
|
160
|
+
const cells = [];
|
|
161
|
+
for (let r = 0; r < rows; r++) {
|
|
162
|
+
for (let c = 0; c < cols; c++) {
|
|
163
|
+
const box = [vx + c * cw, vy + r * ch, cw, ch].map((v) => Math.round(v * 10) / 10);
|
|
164
|
+
const copy = inner.replace(/<svg\b[^>]*>/u, (root) => root
|
|
165
|
+
.replace(/\sviewBox="[^"]*"/u, ` viewBox="${box.join(" ")}"`)
|
|
166
|
+
.replace(/\swidth="[^"]*"/u, ` width="${cellPx}px"`)
|
|
167
|
+
.replace(/\sheight="[^"]*"/u, ` height="${cellH}px"`))
|
|
168
|
+
.replaceAll(/\sid="([^"]+)"/gu, ` id="$1-${r}-${c}"`);
|
|
169
|
+
cells.push(`<figure style="margin:0;border:1px solid #999"><figcaption style="font:12px Arial;padding:2px 4px;background:#f2f2f2">crop ${box.join(",")}</figcaption>${copy}</figure>`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const body = `<div style="display:grid;grid-template-columns:repeat(${cols},${cellPx + 2}px);gap:8px;padding:8px">${cells.join("")}</div>`;
|
|
173
|
+
const cssW = cols * (cellPx + 10) + 16;
|
|
174
|
+
const cssH = rows * (cellH + 30) + 16;
|
|
175
|
+
const work = mkdtempSync(join(tmpdir(), "lit-schematic-render-"));
|
|
176
|
+
try {
|
|
177
|
+
const raw = join(work, "raw.png");
|
|
178
|
+
runChrome(chrome, ["--force-device-scale-factor=1.5", `--window-size=${cssW},${cssH}`, `--screenshot=${raw}`], page({ svg, widthMm: cssW / PX_PER_MM, heightMm: cssH / PX_PER_MM, body }), work);
|
|
179
|
+
writeFileSync(out, readFileSync(raw));
|
|
180
|
+
return { out, cells: cols * rows };
|
|
181
|
+
} finally {
|
|
182
|
+
rmSync(work, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function main(argv) {
|
|
187
|
+
const args = argv.slice(2);
|
|
188
|
+
const value = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
|
|
189
|
+
const input = args.find((a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1].startsWith("--") && args[i - 1] !== "--help"));
|
|
190
|
+
if (!input || args.includes("--help")) {
|
|
191
|
+
console.error("Usage: node render.mjs drawing.svg [--png out.png] [--dpi 600] [--pdf out.pdf] [--crop x,y,w,h] [--sheet sweep.png --grid 3x2]");
|
|
192
|
+
return 2;
|
|
193
|
+
}
|
|
194
|
+
const chrome = findChrome();
|
|
195
|
+
if (!chrome) {
|
|
196
|
+
console.error("RENDERER_REQUIRED no Chrome or Chromium found. This script never installs software. Install Google Chrome, or set CHROME_PATH to a Chrome/Chromium binary, then retry.");
|
|
197
|
+
return 2;
|
|
198
|
+
}
|
|
199
|
+
const svg = readFileSync(resolve(input), "utf8");
|
|
200
|
+
const dpi = Number(value("--dpi") ?? 600);
|
|
201
|
+
const crop = value("--crop")?.split(",").map(Number);
|
|
202
|
+
let did = false;
|
|
203
|
+
if (value("--png")) {
|
|
204
|
+
const r = renderPng({ chrome, svg, out: resolve(value("--png")), dpi, crop });
|
|
205
|
+
console.log(`RENDER_PNG ${r.out} ${r.width}x${r.height} px ${r.dpi} dpi${r.dpi < dpi ? ` (reduced from ${dpi}: the image would exceed what one browser frame paints; render crops for more)` : ""}`);
|
|
206
|
+
did = true;
|
|
207
|
+
}
|
|
208
|
+
if (value("--pdf")) {
|
|
209
|
+
const r = renderPdf({ chrome, svg, out: resolve(value("--pdf")) });
|
|
210
|
+
console.log(`RENDER_PDF ${r.out} ${Math.round(r.widthMm * 10) / 10} x ${Math.round(r.heightMm * 10) / 10} mm`);
|
|
211
|
+
did = true;
|
|
212
|
+
}
|
|
213
|
+
if (value("--sheet")) {
|
|
214
|
+
const grid = (value("--grid") ?? "3x2").split("x").map(Number);
|
|
215
|
+
const r = renderSheet({ chrome, svg, out: resolve(value("--sheet")), grid });
|
|
216
|
+
console.log(`RENDER_SHEET ${r.out} ${r.cells} close-ups of ${basename(input)}`);
|
|
217
|
+
did = true;
|
|
218
|
+
}
|
|
219
|
+
if (!did) {
|
|
220
|
+
console.error("Nothing to do: pass --png, --pdf or --sheet.");
|
|
221
|
+
return 2;
|
|
222
|
+
}
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
227
|
+
try {
|
|
228
|
+
process.exitCode = main(process.argv);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error(error.message);
|
|
231
|
+
process.exitCode = 1;
|
|
232
|
+
}
|
|
233
|
+
}
|