@trueclone/cli 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +145 -2
- package/bin/trueclone.mjs +146 -0
- package/package.json +62 -4
- package/scripts/capture.mjs +248 -0
- package/scripts/curves.mjs +254 -0
- package/scripts/design.mjs +491 -0
- package/scripts/detail.mjs +225 -0
- package/scripts/diff.mjs +88 -0
- package/scripts/hovers.mjs +116 -0
- package/scripts/interact.mjs +188 -0
- package/scripts/lib.mjs +423 -0
- package/scripts/motion.mjs +239 -0
- package/scripts/next.mjs +532 -0
- package/scripts/report.mjs +114 -0
- package/scripts/reveals.mjs +191 -0
- package/scripts/rules.mjs +109 -0
- package/scripts/score.mjs +82 -0
- package/scripts/seed.mjs +513 -0
- package/scripts/spec.mjs +78 -0
- package/scripts/states.mjs +173 -0
- package/scripts/textshape.mjs +384 -0
- package/scripts/tiles.mjs +25 -0
- package/scripts/widgets.mjs +169 -0
- package/skill/SKILL.md +158 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Multi-state capture: scroll the live page like a user (animations running), and at every step record
|
|
2
|
+
// a viewport screenshot (+ a masked one for structure scoring) and every element's opacity/transform/clip/
|
|
3
|
+
// position. From that it derives a motion inventory: scroll reveals, parallax, pinned and fixed elements.
|
|
4
|
+
// With --clone it replays the same scroll positions on the rebuild and scores each step.
|
|
5
|
+
//
|
|
6
|
+
// usage: node scripts/states.mjs <name> [--step 0.5] [--viewports desktop,mobile] [--cdp]
|
|
7
|
+
// node scripts/states.mjs <name> --clone [url] [--structure]
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, wheelTo, statesInPage, maskToggle, freezeLoops, matchPct, PNG } from './lib.mjs';
|
|
11
|
+
|
|
12
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
13
|
+
const [name] = pos;
|
|
14
|
+
if (!name) {
|
|
15
|
+
console.error('usage: npm run states -- <name> [--step 0.5] [--viewports desktop] | <name> --clone [url] [--structure]');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const root = path.resolve('sites', name);
|
|
19
|
+
const ref = path.join(root, 'reference');
|
|
20
|
+
const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
|
|
21
|
+
const cloneMode = Boolean(flags.clone);
|
|
22
|
+
const structure = Boolean(flags.structure);
|
|
23
|
+
const stepFrac = Number(flags.step || 0.5);
|
|
24
|
+
const wanted = typeof flags.viewports === 'string' ? flags.viewports.split(',') : null;
|
|
25
|
+
const viewports = VIEWPORTS.filter((v) => !wanted || wanted.includes(v.name));
|
|
26
|
+
const outRoot = path.join(cloneMode ? path.join(root, 'compare') : ref, 'states');
|
|
27
|
+
|
|
28
|
+
const server = cloneMode && typeof flags.clone !== 'string' ? await serve(path.join(root, 'clone')) : null;
|
|
29
|
+
const target = cloneMode ? (typeof flags.clone === 'string' ? flags.clone : server.url) : meta.url;
|
|
30
|
+
const browser = await openBrowser(flags.cdp);
|
|
31
|
+
const report = { name, target, mode: cloneMode ? (structure ? 'clone-structure' : 'clone-visual') : 'reference', at: new Date().toISOString(), viewports: [] };
|
|
32
|
+
|
|
33
|
+
// Viewports run side by side (one page each): most of a step is waiting for smooth scroll to glide.
|
|
34
|
+
try {
|
|
35
|
+
report.viewports = await Promise.all(viewports.map(async (vp) => {
|
|
36
|
+
const dir = path.join(outRoot, vp.name);
|
|
37
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
38
|
+
await fs.mkdir(dir, { recursive: true });
|
|
39
|
+
const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
|
|
40
|
+
await page.goto(target, { waitUntil: 'load', timeout: 60000 });
|
|
41
|
+
await page.mouse.move(5, vp.height - 5);
|
|
42
|
+
const intro = await waitStill(page, { timeout: 15000, interval: 200 }); // let intros/loaders finish first
|
|
43
|
+
// Before the first scroll: one-shot reveals (Framer Motion whileInView once, IntersectionObserver classes) are
|
|
44
|
+
// still in their starting state here. The warm-up below fires them for good, so they must be read now.
|
|
45
|
+
const initial = cloneMode ? null : await page.evaluate(statesInPage);
|
|
46
|
+
await warmUp(page);
|
|
47
|
+
await wheelTo(page, 0, vp);
|
|
48
|
+
|
|
49
|
+
// Reference: step through the whole page. Clone: replay the reference's exact scroll positions.
|
|
50
|
+
// Guard: a clone whose page height runs away (e.g. a slider sizing loop) would make every step meaningless
|
|
51
|
+
const docH = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
52
|
+
let ys;
|
|
53
|
+
if (cloneMode) {
|
|
54
|
+
const refMeta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
|
|
55
|
+
const refH = refMeta.viewports?.find((v) => v.viewport === vp.name)?.pageHeight;
|
|
56
|
+
if (refH && Math.abs(docH - refH) / refH > 0.2) console.warn(`WARNING ${vp.name}: clone page height ${docH}px vs reference ${refH}px after the warm-up scroll - a layout is running away; fix that before reading state scores.`);
|
|
57
|
+
ys = JSON.parse(await fs.readFile(path.join(ref, 'states', `${vp.name}.json`), 'utf8')).steps.map((s) => s.y);
|
|
58
|
+
} else {
|
|
59
|
+
const max = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
|
|
60
|
+
const step = Math.round(vp.height * stepFrac);
|
|
61
|
+
ys = [];
|
|
62
|
+
for (let y = 0; y < max; y += step) ys.push(y);
|
|
63
|
+
ys.push(max);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const steps = [];
|
|
67
|
+
const samples = []; // per step: { path: state }
|
|
68
|
+
for (const [i, y] of ys.entries()) {
|
|
69
|
+
const actual = await wheelTo(page, y, vp);
|
|
70
|
+
const still = await waitStill(page);
|
|
71
|
+
const file = `${String(i).padStart(2, '0')}`;
|
|
72
|
+
await freezeLoops(page); // loops that started on entering view too, so every step is compared at the same phase
|
|
73
|
+
await page.screenshot({ path: path.join(dir, `${file}.png`) });
|
|
74
|
+
await maskToggle(page, true);
|
|
75
|
+
await page.screenshot({ path: path.join(dir, `${file}.mask.png`) });
|
|
76
|
+
await maskToggle(page, false);
|
|
77
|
+
if (!cloneMode) samples.push(await page.evaluate(statesInPage));
|
|
78
|
+
steps.push({ i, y: actual, target: y, settled: still.settled, settleMs: still.ms });
|
|
79
|
+
}
|
|
80
|
+
await context.close();
|
|
81
|
+
|
|
82
|
+
if (cloneMode) {
|
|
83
|
+
const sfx = structure ? '.mask' : '';
|
|
84
|
+
const scored = [];
|
|
85
|
+
for (const s of steps) {
|
|
86
|
+
const f = `${String(s.i).padStart(2, '0')}${sfx}.png`;
|
|
87
|
+
const a = PNG.sync.read(await fs.readFile(path.join(ref, 'states', vp.name, f)));
|
|
88
|
+
const b = PNG.sync.read(await fs.readFile(path.join(dir, f)));
|
|
89
|
+
scored.push({ ...s, match: matchPct(a, b).pct });
|
|
90
|
+
}
|
|
91
|
+
const avg = +(scored.reduce((n, s) => n + s.match, 0) / scored.length).toFixed(2);
|
|
92
|
+
return { viewport: vp.name, match: avg, worst: [...scored].sort((a, b) => a.match - b.match).slice(0, 5), steps: scored };
|
|
93
|
+
} else {
|
|
94
|
+
const summary = summarize(steps, samples, vp, initial);
|
|
95
|
+
await fs.writeFile(path.join(outRoot, `${vp.name}.json`), JSON.stringify({ viewport: vp, steps, samples }));
|
|
96
|
+
await fs.writeFile(path.join(outRoot, `${vp.name}.summary.json`), JSON.stringify(summary, null, 2));
|
|
97
|
+
return { viewport: vp.name, introMs: intro.ms, steps: steps.length, unsettled: steps.filter((s) => !s.settled).length, ...summary.counts };
|
|
98
|
+
}
|
|
99
|
+
}));
|
|
100
|
+
} finally {
|
|
101
|
+
if (!flags.cdp) await browser.close();
|
|
102
|
+
server?.close();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await fs.writeFile(path.join(outRoot, cloneMode ? `report${structure ? '.mask' : ''}.json` : 'report.json'), JSON.stringify(report, null, 2));
|
|
106
|
+
if (cloneMode) {
|
|
107
|
+
console.log(`state match (${report.mode}) - average over scroll steps`);
|
|
108
|
+
console.table(report.viewports.map((v) => ({ viewport: v.viewport, 'match %': v.match, worst: v.worst.map((s) => `#${s.i} y${s.y} ${s.match}%`).join(' ') })));
|
|
109
|
+
} else {
|
|
110
|
+
console.table(report.viewports);
|
|
111
|
+
}
|
|
112
|
+
console.log(`States: ${path.relative(process.cwd(), outRoot)}`);
|
|
113
|
+
|
|
114
|
+
// ---------- Motion inventory ----------
|
|
115
|
+
// Classifies what changes between scroll steps, per element (keyed by DOM path).
|
|
116
|
+
function summarize(steps, samples, vp, initial = {}) {
|
|
117
|
+
const inView = (st) => st.box[1] < vp.height && st.box[1] + st.box[3] > 0;
|
|
118
|
+
const paths = new Set(samples.flatMap((s) => Object.keys(s)));
|
|
119
|
+
const reveals = [], animated = [], parallax = [], pinned = [], fixed = [], revealsOnce = [];
|
|
120
|
+
|
|
121
|
+
// One-shot reveals: below the fold at load in a hidden or offset starting state (opacity ~0, a translate, a blur),
|
|
122
|
+
// and seen visible in view later. The starting state is the recipe the clone has to animate from.
|
|
123
|
+
const offset = (t) => { const m = /matrix\(([^)]+)\)/.exec(t || ''); if (!m) return 0; const v = m[1].split(',').map(Number); return Math.hypot(v[4], v[5]); };
|
|
124
|
+
for (const [p, s0] of Object.entries(initial || {})) {
|
|
125
|
+
if (s0.box[1] < vp.height) continue; // above the fold: part of the intro, not a scroll reveal
|
|
126
|
+
const hidden = s0.o <= 0.05 || !s0.v || offset(s0.t) >= 8 || /blur/.test(s0.f);
|
|
127
|
+
if (!hidden) continue;
|
|
128
|
+
const later = samples.map((s) => s[p]).find((s) => s && inView(s) && s.o > 0.5 && s.v);
|
|
129
|
+
if (!later || (later.o === s0.o && later.t === s0.t && later.f === s0.f)) continue;
|
|
130
|
+
revealsOnce.push({ path: p, cls: s0.cls, from: { opacity: s0.o, transform: s0.t || 'none', filter: s0.f || 'none' }, to: { opacity: later.o, transform: later.t || 'none', filter: later.f || 'none' }, box: later.box });
|
|
131
|
+
}
|
|
132
|
+
for (const p of paths) {
|
|
133
|
+
const seq = samples.map((s, i) => (s[p] ? { ...s[p], i, y: steps[i].y } : null));
|
|
134
|
+
const present = seq.filter(Boolean);
|
|
135
|
+
if (present.length < 2) continue;
|
|
136
|
+
const first = present[0];
|
|
137
|
+
const label = { path: p, cls: first.cls, media: first.media };
|
|
138
|
+
|
|
139
|
+
if (present.some((s) => s.pos === 'fixed')) fixed.push({ ...label, box: first.box });
|
|
140
|
+
|
|
141
|
+
// Hidden while in view at one step, visible while in view at a later step => scroll reveal.
|
|
142
|
+
const shownAt = present.find((s) => inView(s) && s.o > 0.05 && s.v);
|
|
143
|
+
const hiddenBefore = present.find((s) => inView(s) && (s.o <= 0.05 || !s.v) && (!shownAt || s.i < shownAt.i));
|
|
144
|
+
if (hiddenBefore && shownAt) reveals.push({ ...label, hiddenAtStep: hiddenBefore.i, shownAtStep: shownAt.i, box: shownAt.box });
|
|
145
|
+
|
|
146
|
+
// Properties that change between steps.
|
|
147
|
+
const props = ['o', 't', 'c', 'f'].filter((k) => new Set(present.map((s) => String(s[k]))).size > 1);
|
|
148
|
+
if (props.length) animated.push({ ...label, props: props.map((k) => ({ o: 'opacity', t: 'transform', c: 'clip-path', f: 'filter' })[k]), steps: present.length });
|
|
149
|
+
|
|
150
|
+
// Scroll speed relative to the page: 1 = normal, 0 = pinned, other = parallax.
|
|
151
|
+
const pairs = [];
|
|
152
|
+
for (let k = 1; k < present.length; k++) {
|
|
153
|
+
const a = present[k - 1], b = present[k];
|
|
154
|
+
const dScroll = b.y - a.y;
|
|
155
|
+
if (dScroll <= 0 || !inView(a) || !inView(b)) continue;
|
|
156
|
+
pairs.push(-(b.box[1] - a.box[1]) / dScroll);
|
|
157
|
+
}
|
|
158
|
+
if (pairs.length >= 2 && !present.some((s) => s.pos === 'fixed')) {
|
|
159
|
+
const speed = pairs.reduce((n, v) => n + v, 0) / pairs.length;
|
|
160
|
+
if (Math.abs(speed) < 0.05) pinned.push({ ...label, steps: pairs.length });
|
|
161
|
+
else if (Math.abs(speed - 1) > 0.08) parallax.push({ ...label, speed: +speed.toFixed(2) });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Keep the list readable: outermost elements first (shorter paths), deduplicate children of listed parents.
|
|
165
|
+
const prune = (list) => {
|
|
166
|
+
list.sort((a, b) => a.path.length - b.path.length);
|
|
167
|
+
const kept = [];
|
|
168
|
+
for (const item of list) if (!kept.some((k) => item.path.startsWith(k.path + '>'))) kept.push(item);
|
|
169
|
+
return kept;
|
|
170
|
+
};
|
|
171
|
+
const result = { reveals: prune(reveals), revealsOnce: prune(revealsOnce), animated: prune(animated), parallax: prune(parallax), pinned: prune(pinned), fixed: prune(fixed) };
|
|
172
|
+
return { counts: Object.fromEntries(Object.entries(result).map(([k, v]) => [k, v.length])), ...result };
|
|
173
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// Template mode: give the clone's placeholder text the reference's shape. For every text block on the clone, the
|
|
2
|
+
// reference block at the same spot on screen is found, and each reference word is replaced by a lorem word of
|
|
3
|
+
// (nearly) the same rendered width in the reference's own font, keeping capitals, punctuation, number lengths, years. Lines then
|
|
4
|
+
// break where the reference's lines break, so layout and detail scores measure styling instead of copy length.
|
|
5
|
+
// Only placeholder words are written; no reference copy ends up in the clone.
|
|
6
|
+
//
|
|
7
|
+
// Blocks are matched at scroll windows (half a viewport apart, so pinned chapters are seen one by one). A clone
|
|
8
|
+
// block is rewritten only when its text is found verbatim in clone/index.html (or as a string literal in
|
|
9
|
+
// clone/script.js, for lists the script renders from data). Inline parts (a coloured span inside a heading) are
|
|
10
|
+
// shaped part by part. Text that already has the reference's shape (same word count, width within 4%) is kept, and a
|
|
11
|
+
// pair is only trusted when both blocks line up (left within 12px; width within 10% for blocks that fill their parent). Run desktop first, then
|
|
12
|
+
// the other viewports for blocks that only exist there; text that is already placeholder words is never reshaped.
|
|
13
|
+
//
|
|
14
|
+
// usage: node scripts/textshape.mjs <name> [--viewport desktop] [--write] [--debug]
|
|
15
|
+
// node scripts/textshape.mjs <name> --list "<reference row selector>" "<clone row selector>" [--write]
|
|
16
|
+
// (repeated lists such as calendar rows or cards: row n takes row n's shape, block by block; this also
|
|
17
|
+
// works where the list still drifts, and for a placeholder repeated in a data array)
|
|
18
|
+
import fs from 'node:fs/promises';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { VIEWPORTS, parseArgs, openBrowser, newPage, settle, serve, waitStill } from './lib.mjs';
|
|
21
|
+
|
|
22
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
23
|
+
const [name] = pos;
|
|
24
|
+
if (!name) {
|
|
25
|
+
console.error('usage: npm run textshape -- <name> [--viewport desktop] [--write]');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const root = path.resolve('sites', name);
|
|
29
|
+
const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
|
|
30
|
+
if (meta.mode === 'exact') {
|
|
31
|
+
console.error('exact mode clones carry the real text already; textshape is for template clones');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
|
|
35
|
+
const listAt = process.argv.indexOf('--list');
|
|
36
|
+
const listSelectors = listAt > 0 ? process.argv.slice(listAt + 1, listAt + 3) : null;
|
|
37
|
+
if (listAt > 0 && (listSelectors.length < 2 || listSelectors.some((x) => x.startsWith('--')))) { console.error('--list needs two selectors: the reference row and the clone row'); process.exit(1); }
|
|
38
|
+
const htmlFile = path.join(root, 'clone', 'index.html');
|
|
39
|
+
const jsFile = path.join(root, 'clone', 'script.js');
|
|
40
|
+
|
|
41
|
+
const LOREM = `lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore
|
|
42
|
+
magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute
|
|
43
|
+
irure in reprehenderit voluptate velit esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt
|
|
44
|
+
culpa qui officia deserunt mollit anim id est laborum curabitur pretium tincidunt lacus nunc vitae faucibus porta felis
|
|
45
|
+
mauris viverra diam vel augue blandit nibh sapien rhoncus massa feugiat tellus integer vulputate morbi risus quam varius
|
|
46
|
+
orci nam libero justo laoreet sem lectus mattis praesent semper ante gravida eget fermentum turpis sodales neque arcu
|
|
47
|
+
egestas phasellus volutpat donec pharetra aenean placerat lobortis primis cras tortor iaculis lacinia suspendisse potenti
|
|
48
|
+
vivamus ultricies posuere cubilia curae maecenas ligula proin accumsan quisque ornare urna pellentesque habitant tristique
|
|
49
|
+
senectus netus malesuada fames ac a at nec mi ne te se eu per`.split(/\s+/).filter(Boolean);
|
|
50
|
+
const LOREM_SET = new Set(LOREM);
|
|
51
|
+
|
|
52
|
+
// Same rule on both pages: a text node belongs to its nearest block-level ancestor, skipping split-text wrappers.
|
|
53
|
+
const BLOCK_RULE = `
|
|
54
|
+
const SPLIT = /(^|[-_\\s])(line|word|char)s?([-_\\s]|$)|split|reveal-/i;
|
|
55
|
+
const own = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
|
|
56
|
+
const norm = (t) => t.replace(/\\s+/g, ' ').trim();
|
|
57
|
+
const blockOf = (el) => {
|
|
58
|
+
let b = el;
|
|
59
|
+
while (b.parentElement && b.parentElement !== document.body) {
|
|
60
|
+
const d = getComputedStyle(b).display;
|
|
61
|
+
// one-or-two-character inline-blocks are split text too (odometer digits, per-letter hover rolls)
|
|
62
|
+
const splitChar = d === 'inline-block' && norm(b.textContent).length <= 2;
|
|
63
|
+
if (!['inline', 'contents'].includes(d) && !splitChar && !SPLIT.test(String(b.getAttribute('class') || ''))) break;
|
|
64
|
+
b = b.parentElement;
|
|
65
|
+
}
|
|
66
|
+
return b;
|
|
67
|
+
};`;
|
|
68
|
+
|
|
69
|
+
// Clone: text blocks actually on screen, with a stable id and which duplicate (same text) they are.
|
|
70
|
+
const blocksInView = `(() => {${BLOCK_RULE}
|
|
71
|
+
const visible = (el) => {
|
|
72
|
+
// closed <details> content still reports a box but is never painted
|
|
73
|
+
if (el.checkVisibility && !el.checkVisibility({ contentVisibilityAuto: true })) return false;
|
|
74
|
+
for (let p = el; p && p !== document.body; p = p.parentElement) {
|
|
75
|
+
const s = getComputedStyle(p);
|
|
76
|
+
if (s.visibility === 'hidden' || +s.opacity === 0 || s.display === 'none') return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
// hit tests skip pointer-events:none layers (text overlays often are), so switch it on while testing
|
|
81
|
+
const hit = document.createElement('style');
|
|
82
|
+
hit.textContent = '*{pointer-events:auto!important}';
|
|
83
|
+
document.head.append(hit);
|
|
84
|
+
const found = new Map();
|
|
85
|
+
for (const el of document.body.querySelectorAll('*')) {
|
|
86
|
+
if (!own(el) || ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXT', 'TSPAN'].includes(el.tagName.toUpperCase())) continue;
|
|
87
|
+
const b = blockOf(el);
|
|
88
|
+
if (found.has(b) || !visible(b)) continue;
|
|
89
|
+
const range = document.createRange();
|
|
90
|
+
range.selectNodeContents(b);
|
|
91
|
+
const first = [...range.getClientRects()].find((r) => r.width > 2 && r.height > 2);
|
|
92
|
+
if (!first || first.top < 0 || first.bottom > innerHeight || first.left < 0 || first.right > innerWidth) continue;
|
|
93
|
+
const pt = { x: first.left + Math.min(6, first.width / 2), y: first.top + first.height / 2 };
|
|
94
|
+
// clipped text (a closed menu behind a clip-path) drops out of hit testing; decorative layers above the text
|
|
95
|
+
// (guide lines, gradients) are fine, so the block only has to be somewhere in the stack at its point
|
|
96
|
+
if (!document.elementsFromPoint(pt.x, pt.y).some((e) => e === b || b.contains(e))) continue;
|
|
97
|
+
found.set(b, { ...pt, top: first.top });
|
|
98
|
+
}
|
|
99
|
+
window.__labIds = window.__labIds || 0;
|
|
100
|
+
hit.remove();
|
|
101
|
+
return [...found].map(([b, p]) => {
|
|
102
|
+
if (!b.dataset.labId) b.dataset.labId = String(++window.__labIds);
|
|
103
|
+
const text = norm(b.textContent);
|
|
104
|
+
const same = [...document.body.querySelectorAll('*')].filter((e) => norm(e.textContent) === text && ![...e.children].some((c) => norm(c.textContent) === text));
|
|
105
|
+
const dup = same.findIndex((e) => e === b || b.contains(e) || e.contains(b));
|
|
106
|
+
const box = b.getBoundingClientRect();
|
|
107
|
+
const fills = box.width >= 0.9 * b.parentElement.getBoundingClientRect().width;
|
|
108
|
+
const cs = getComputedStyle(b);
|
|
109
|
+
return { id: b.dataset.labId, x: p.x, y: p.y, top: p.top, boxTop: box.top, boxBottom: box.bottom, left: box.left, width: box.width, fills, family: cs.fontFamily.split(',')[0].replace(/["']/g, '').trim().toLowerCase(), size: parseFloat(cs.fontSize), text, dup, dups: same.length };
|
|
110
|
+
});
|
|
111
|
+
})()`;
|
|
112
|
+
|
|
113
|
+
// A block's text split into parts by inline styling (a coloured span is its own part), with each part's font.
|
|
114
|
+
const DESCRIBE = `${BLOCK_RULE}
|
|
115
|
+
const describe = (b) => {
|
|
116
|
+
const parts = [];
|
|
117
|
+
const walk = (node) => {
|
|
118
|
+
for (const n of node.childNodes) {
|
|
119
|
+
if (n.nodeType === 3) {
|
|
120
|
+
if (!n.textContent.trim() && !parts.length) continue;
|
|
121
|
+
let el = n.parentElement;
|
|
122
|
+
while (el !== b && SPLIT.test(String(el.getAttribute('class') || ''))) el = el.parentElement;
|
|
123
|
+
// keyed by the inline element's class, not its identity: split-text libraries clone a coloured span on
|
|
124
|
+
// every line it crosses, and those copies are one part
|
|
125
|
+
const key = el === b ? '' : String(el.getAttribute('class') || el.tagName);
|
|
126
|
+
const last = parts[parts.length - 1];
|
|
127
|
+
if (last && last.key === key && !last.closed) last.text += n.textContent;
|
|
128
|
+
else {
|
|
129
|
+
const s = getComputedStyle(n.parentElement);
|
|
130
|
+
parts.push({ key, text: n.textContent, font: s.fontStyle + ' ' + s.fontWeight + ' ' + s.fontSize + ' ' + s.fontFamily, upper: s.textTransform === 'uppercase', ls: parseFloat(s.letterSpacing) || 0 });
|
|
131
|
+
}
|
|
132
|
+
} else if (n.nodeType === 1) {
|
|
133
|
+
if (n.tagName === 'BR') { const last = parts[parts.length - 1]; if (last) last.closed = true; continue; }
|
|
134
|
+
if (getComputedStyle(n).display === 'none') continue;
|
|
135
|
+
walk(n);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
walk(b);
|
|
140
|
+
return {
|
|
141
|
+
boxTop: b.getBoundingClientRect().top,
|
|
142
|
+
boxBottom: b.getBoundingClientRect().bottom,
|
|
143
|
+
top: (() => { const rg = document.createRange(); rg.selectNodeContents(b); const f = [...rg.getClientRects()].find((x) => x.width > 2 && x.height > 2); return f ? f.top : b.getBoundingClientRect().top; })(),
|
|
144
|
+
left: b.getBoundingClientRect().left,
|
|
145
|
+
width: b.getBoundingClientRect().width,
|
|
146
|
+
fills: b.getBoundingClientRect().width >= 0.9 * b.parentElement.getBoundingClientRect().width,
|
|
147
|
+
family: getComputedStyle(b).fontFamily.split(',')[0].replace(/["']/g, '').trim().toLowerCase(),
|
|
148
|
+
size: parseFloat(getComputedStyle(b).fontSize),
|
|
149
|
+
tag: b.tagName.toLowerCase(),
|
|
150
|
+
cls: String(b.getAttribute('class') || '').split(/\\s+/).slice(0, 2).join('.'),
|
|
151
|
+
text: norm(b.textContent),
|
|
152
|
+
...(() => { const t = norm(b.textContent); const same = [...document.body.querySelectorAll('*')].filter((e) => norm(e.textContent) === t && ![...e.children].some((c) => norm(c.textContent) === t)); return { dup: same.findIndex((e) => e === b || b.contains(e) || e.contains(b)), dups: same.length }; })(),
|
|
153
|
+
parts: parts.map((p) => ({ text: norm(p.text), font: p.font, upper: p.upper, ls: p.ls })).filter((p) => p.text),
|
|
154
|
+
};
|
|
155
|
+
};`;
|
|
156
|
+
|
|
157
|
+
// Reference: the block under a point.
|
|
158
|
+
const refBlockAt = (x, y) => `(() => {${DESCRIBE}
|
|
159
|
+
const hit = document.createElement('style');
|
|
160
|
+
hit.textContent = '*{pointer-events:auto!important}';
|
|
161
|
+
document.head.append(hit);
|
|
162
|
+
const leaf = document.elementsFromPoint(${x}, ${y}).find(own);
|
|
163
|
+
hit.remove();
|
|
164
|
+
return leaf ? describe(blockOf(leaf)) : null;
|
|
165
|
+
})()`;
|
|
166
|
+
|
|
167
|
+
// List mode: every row of a repeated list (rows, cards), and the text blocks inside each row in reading order.
|
|
168
|
+
const listRows = (sel) => `(() => {${DESCRIBE}
|
|
169
|
+
return [...document.querySelectorAll(${JSON.stringify(sel)})].map((row) => {
|
|
170
|
+
const blocks = [];
|
|
171
|
+
for (const el of row.querySelectorAll('*')) {
|
|
172
|
+
if (!own(el) || ['SCRIPT', 'STYLE', 'TEXT', 'TSPAN'].includes(el.tagName.toUpperCase())) continue;
|
|
173
|
+
const b = blockOf(el);
|
|
174
|
+
// only text that renders: hidden CMS fields (display: none) are not part of the layout
|
|
175
|
+
if (!blocks.includes(b) && row.contains(b) && b.getClientRects().length) blocks.push(b);
|
|
176
|
+
}
|
|
177
|
+
return blocks.map(describe);
|
|
178
|
+
});
|
|
179
|
+
})()`;
|
|
180
|
+
|
|
181
|
+
// Does the clone text already have the reference's shape (same word count, width within 4%)?
|
|
182
|
+
function sameShapeInPage([refParts, cloneParts]) {
|
|
183
|
+
const ctx = document.createElement('canvas').getContext('2d');
|
|
184
|
+
return refParts.every((p, k) => {
|
|
185
|
+
ctx.font = p.font;
|
|
186
|
+
const words = (t) => t.split(/\s+/).filter(Boolean);
|
|
187
|
+
const w = (t) => ctx.measureText(p.upper ? t.toUpperCase() : t).width;
|
|
188
|
+
const c = cloneParts[k] || '';
|
|
189
|
+
return words(p.text).length === words(c).length && Math.abs(w(p.text) - w(c)) <= 0.04 * w(p.text);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Width-matched placeholder words, measured in the reference font.
|
|
194
|
+
function shapeInPage([parts, lorem]) {
|
|
195
|
+
const ctx = document.createElement('canvas').getContext('2d');
|
|
196
|
+
let seed = 7;
|
|
197
|
+
return parts.map((p) => {
|
|
198
|
+
ctx.font = p.font;
|
|
199
|
+
const width = (w) => ctx.measureText(p.upper ? w.toUpperCase() : w).width + p.ls * w.length;
|
|
200
|
+
const pool = lorem.map((w) => [w, width(w)]);
|
|
201
|
+
// match the running width, not just each word: every pick corrects the drift left by the previous ones, so
|
|
202
|
+
// the line-break points stay where the reference's are even over long paragraphs
|
|
203
|
+
let drift = 0;
|
|
204
|
+
return p.text.split(/(\s+)/).map((tok) => {
|
|
205
|
+
if (!tok.trim()) return ' ';
|
|
206
|
+
const m = tok.match(/^([^\p{L}\p{N}]*)([\p{L}\p{N}][\p{L}\p{N}'’-]*?)?([^\p{L}\p{N}]*)$/u);
|
|
207
|
+
if (!m || !m[2]) return tok; // punctuation only
|
|
208
|
+
const [, pre, word, post] = m;
|
|
209
|
+
// numbers keep their length (and years stay), but not their digits: a car number or a podium count is the
|
|
210
|
+
// original's data, not layout
|
|
211
|
+
if (/^(19|20)\d\d$/.test(word)) return tok;
|
|
212
|
+
if (/^[\d.,:/+-]+$/.test(word)) return pre + word.replace(/\d/g, (d) => String((Number(d) + 3) % 10)) + post;
|
|
213
|
+
// candidates get the word's capitalisation before they are measured (capitals are wider)
|
|
214
|
+
const caps = word.length > 1 && word === word.toUpperCase() && /\p{L}/u.test(word);
|
|
215
|
+
const cap = !caps && /\p{Lu}/u.test(word[0]);
|
|
216
|
+
const cased = (w) => (caps ? w.toUpperCase() : cap ? w[0].toUpperCase() + w.slice(1) : w);
|
|
217
|
+
const target = width(word) + drift;
|
|
218
|
+
const ranked = pool.map(([w]) => [cased(w), Math.abs(width(cased(w)) - target)]).sort((a, b) => a[1] - b[1]);
|
|
219
|
+
const close = ranked.filter(([, d]) => d <= ranked[0][1] + 0.5).map(([w]) => w);
|
|
220
|
+
seed = (seed * 31 + word.length) % 9973;
|
|
221
|
+
const out = close[seed % close.length];
|
|
222
|
+
drift = target - width(out);
|
|
223
|
+
return pre + out + post;
|
|
224
|
+
}).join('').replace(/\s+/g, ' ').trim();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Clone source: the block's own text parts in clone/index.html (parsed, no scripts).
|
|
229
|
+
function sourcePartsInPage([blockText, dup, dups, html]) {
|
|
230
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
231
|
+
const norm = (s) => s.replace(/\s+/g, ' ').trim();
|
|
232
|
+
const candidates = [...doc.body.querySelectorAll('*')].filter((e) => norm(e.textContent) === blockText && ![...e.children].some((c) => norm(c.textContent) === blockText));
|
|
233
|
+
if (!candidates.length) return { error: 'not found verbatim in the source' };
|
|
234
|
+
if (candidates.length !== dups || dup < 0) return { error: `${candidates.length} identical blocks in the source, ${dups} on the page` };
|
|
235
|
+
const el = candidates[dup];
|
|
236
|
+
const allText = [];
|
|
237
|
+
const tw = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
|
238
|
+
for (let n = tw.nextNode(); n; n = tw.nextNode()) allText.push(n);
|
|
239
|
+
const parts = [];
|
|
240
|
+
const walk = (node) => {
|
|
241
|
+
for (const n of node.childNodes) {
|
|
242
|
+
if (n.nodeType === 3) {
|
|
243
|
+
if (!n.textContent.trim()) continue;
|
|
244
|
+
const k = allText.indexOf(n);
|
|
245
|
+
const occurrence = allText.filter((t, j) => j < k && norm(t.textContent) === norm(n.textContent)).length;
|
|
246
|
+
const last = parts[parts.length - 1];
|
|
247
|
+
if (last && last.parent === n.parentElement && !last.closed) last.nodes.push({ text: n.textContent, occurrence });
|
|
248
|
+
else parts.push({ parent: n.parentElement, nodes: [{ text: n.textContent, occurrence }] });
|
|
249
|
+
} else if (n.nodeType === 1) {
|
|
250
|
+
if (n.tagName === 'BR') { const last = parts[parts.length - 1]; if (last) last.closed = true; continue; }
|
|
251
|
+
walk(n);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
walk(el);
|
|
256
|
+
return { parts: parts.map((p) => p.nodes) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const LITERAL = /(['"`])((?:\\.|(?!\1).)*)\1/g; // string literals in script.js
|
|
260
|
+
const server = await serve(path.join(root, 'clone'));
|
|
261
|
+
const browser = await openBrowser(flags.cdp);
|
|
262
|
+
const html = await fs.readFile(htmlFile, 'utf8');
|
|
263
|
+
const js = await fs.readFile(jsFile, 'utf8').catch(() => '');
|
|
264
|
+
const plan = [];
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
try {
|
|
267
|
+
const open = async (url) => {
|
|
268
|
+
const { page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
|
|
269
|
+
await page.goto(url, { waitUntil: 'load', timeout: 60000 });
|
|
270
|
+
await page.mouse.move(5, vp.height - 5);
|
|
271
|
+
await waitStill(page, { timeout: 15000, interval: 200 });
|
|
272
|
+
await settle(page);
|
|
273
|
+
return page;
|
|
274
|
+
};
|
|
275
|
+
const [ref, clone] = await Promise.all([open(meta.url), open(server.url)]);
|
|
276
|
+
// One pair: reference block r, clone block c ({ text, dup, dups }); jsOccurrence set in list mode.
|
|
277
|
+
const consider = async (r, c, where, jsOccurrence) => {
|
|
278
|
+
if (!r || !r.text || r.text === c.text) return; // nothing, or already the same words (generic labels)
|
|
279
|
+
const words = c.text.toLowerCase().match(/\p{L}+/gu) || [];
|
|
280
|
+
if (words.length && words.every((w) => LOREM_SET.has(w))) { // shaped on an earlier pass
|
|
281
|
+
if (flags.debug) console.log(` already placeholder words: "${c.text.slice(0, 40)}"`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const label = `${r.tag}.${r.cls}`;
|
|
285
|
+
let src = await clone.evaluate(sourcePartsInPage, [c.text, c.dup, c.dups, html]);
|
|
286
|
+
if (src.error && r.parts.length === 1) {
|
|
287
|
+
const lits = [...js.matchAll(LITERAL)].filter((m) => m[2] === c.text);
|
|
288
|
+
// a literal repeated across data rows maps by row order in list mode; otherwise only a unique one is safe
|
|
289
|
+
if (jsOccurrence !== undefined && jsOccurrence < lits.length) src = { js: true, parts: [[{ text: c.text, occurrence: jsOccurrence }]] };
|
|
290
|
+
else if (lits.length === 1 && c.dups === 1) src = { js: true, parts: [[{ text: c.text, occurrence: 0 }]] };
|
|
291
|
+
else if (lits.length) src = { error: `string literal repeated ${lits.length}x in script.js (use --list)` };
|
|
292
|
+
}
|
|
293
|
+
if (src.error) { plan.push({ y: where, clone: c.text, ref: label, skip: src.error }); return; }
|
|
294
|
+
if (src.parts.length !== r.parts.length) { plan.push({ y: where, clone: c.text, ref: label, skip: `${src.parts.length} inline parts on the clone vs ${r.parts.length} on the reference` }); return; }
|
|
295
|
+
const cloneParts = src.parts.map((nodes) => nodes.map((n) => n.text).join(' ').replace(/\s+/g, ' ').trim());
|
|
296
|
+
if (await ref.evaluate(sameShapeInPage, [r.parts, cloneParts])) {
|
|
297
|
+
if (flags.debug) console.log(` same shape already: "${c.text.slice(0, 40)}"`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const shaped = await ref.evaluate(shapeInPage, [r.parts, LOREM]);
|
|
301
|
+
plan.push({ y: where, clone: c.text, ref: label, js: src.js, parts: src.parts.map((nodes, k) => ({ nodes, to: shaped[k] })) });
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
if (listSelectors) {
|
|
305
|
+
// List mode: row n of the clone list takes the shape of row n of the reference list, block by block.
|
|
306
|
+
const [refSel, cloneSel] = listSelectors;
|
|
307
|
+
const [refRows, cloneRows] = await Promise.all([ref.evaluate(listRows(refSel)), clone.evaluate(listRows(cloneSel))]);
|
|
308
|
+
if (refRows.length !== cloneRows.length) console.warn(`WARNING ${refRows.length} reference rows vs ${cloneRows.length} clone rows: pairing the first ${Math.min(refRows.length, cloneRows.length)}`);
|
|
309
|
+
const seenText = new Map(); // text -> how many earlier rows used it (literal occurrence order)
|
|
310
|
+
for (let i = 0; i < Math.min(refRows.length, cloneRows.length); i++) {
|
|
311
|
+
if (refRows[i].length !== cloneRows[i].length) { plan.push({ y: `row ${i + 1}`, clone: cloneRows[i].map((x) => x.text).join(' | '), ref: refSel, skip: `${cloneRows[i].length} text blocks vs ${refRows[i].length}` }); continue; }
|
|
312
|
+
for (let j = 0; j < refRows[i].length; j++) {
|
|
313
|
+
const c = cloneRows[i][j], r = refRows[i][j];
|
|
314
|
+
const occurrence = seenText.get(c.text) || 0;
|
|
315
|
+
seenText.set(c.text, occurrence + 1);
|
|
316
|
+
if (r.family !== c.family) { plan.push({ y: `row ${i + 1}`, clone: c.text, ref: `${r.tag}.${r.cls}`, skip: `font ${c.family} vs ${r.family}` }); continue; }
|
|
317
|
+
await consider(r, c, `row ${i + 1}`, occurrence);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
const max = await clone.evaluate(() => document.documentElement.scrollHeight - innerHeight);
|
|
322
|
+
for (let y = 0; ; y += Math.round(vp.height / 2)) {
|
|
323
|
+
const at = Math.min(y, max);
|
|
324
|
+
await Promise.all([ref, clone].map(async (p) => { await p.evaluate((yy) => window.scrollTo(0, yy), at); await waitStill(p, { timeout: 3000 }); }));
|
|
325
|
+
for (const c of await clone.evaluate(blocksInView)) {
|
|
326
|
+
if (seen.has(c.id)) continue;
|
|
327
|
+
const r = await ref.evaluate(refBlockAt(c.x, c.y));
|
|
328
|
+
if (!r || !r.text) continue;
|
|
329
|
+
seen.add(c.id);
|
|
330
|
+
// Position pairing only holds where the layout already lines up: the blocks' left edges must agree (and
|
|
331
|
+
// widths, for blocks that fill their parent), and the text must be the same kind (family, size). Elsewhere
|
|
332
|
+
// (a section that still drifts at this width) the block under the point is a different block: use --list.
|
|
333
|
+
const why = Math.abs(r.left - c.left) > 12 || (r.fills && c.fills && Math.abs(r.width - c.width) > Math.max(12, 0.1 * r.width))
|
|
334
|
+
? `boxes differ (left ${Math.round(r.left)} vs ${Math.round(c.left)}, width ${Math.round(r.width)} vs ${Math.round(c.width)})`
|
|
335
|
+
// vertically the blocks must line up too (drift): by their first lines, centres or bottoms, so top-, centre-
|
|
336
|
+
// and bottom-aligned layouts all pass while text of different length is still being shaped
|
|
337
|
+
: ![[r.top, c.top], [(r.boxTop + r.boxBottom) / 2, (c.boxTop + c.boxBottom) / 2], [r.boxBottom, c.boxBottom]].some(([p, q]) => Math.abs(p - q) <= Math.max(4, 0.4 * c.size))
|
|
338
|
+
? `first lines at different heights (${Math.round(r.top)} vs ${Math.round(c.top)})`
|
|
339
|
+
: r.family !== c.family || Math.abs(r.size - c.size) > 0.15 * r.size ? `different text (${r.family} ${r.size} vs ${c.family} ${c.size})` : null;
|
|
340
|
+
if (why) {
|
|
341
|
+
if (flags.debug) console.log(` not paired at y${at}: "${c.text.slice(0, 40)}" with ${r.tag}.${r.cls} "${r.text.slice(0, 30)}": ${why}`);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
await consider(r, c, at);
|
|
345
|
+
}
|
|
346
|
+
if (at >= max) break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} finally {
|
|
350
|
+
if (!flags.cdp) await browser.close();
|
|
351
|
+
server.close();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Apply: each source part's text nodes become the shaped text (the first node gets it all, the rest are emptied).
|
|
355
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
356
|
+
const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
357
|
+
const pattern = (text) => new RegExp(text.trim().split(/\s+/).map((w) => reEsc(esc(w)).replace(/'/g, "(?:'|'|')").replace(/’/g, '(?:’|’)')).join('\\s+'), 'g');
|
|
358
|
+
// Edits to the same text are applied from the last occurrence back to the first: replacing an earlier copy
|
|
359
|
+
// first would shift the occurrence numbers of the later ones.
|
|
360
|
+
const edits = [];
|
|
361
|
+
for (const item of plan.filter((p) => p.parts)) {
|
|
362
|
+
if (item.js) { const { text, occurrence } = item.parts[0].nodes[0]; edits.push({ js: true, text, occurrence, to: item.parts[0].to }); continue; }
|
|
363
|
+
for (const part of item.parts) part.nodes.forEach((node, k) => edits.push({ text: node.text, occurrence: node.occurrence, to: k === 0 ? part.to : '' }));
|
|
364
|
+
}
|
|
365
|
+
edits.sort((x, y) => y.occurrence - x.occurrence);
|
|
366
|
+
let out = html;
|
|
367
|
+
let outJs = js;
|
|
368
|
+
for (const e of edits) {
|
|
369
|
+
let n = -1;
|
|
370
|
+
if (e.js) outJs = outJs.replace(LITERAL, (m, q, body) => (body === e.text && ++n === e.occurrence ? q + e.to.split(q).join('\\' + q) + q : m));
|
|
371
|
+
else out = out.replace(pattern(e.text), (m) => (++n === e.occurrence ? esc(e.to) : m));
|
|
372
|
+
}
|
|
373
|
+
const changed = plan.filter((p) => p.parts).reduce((n, p) => n + p.parts.length, 0);
|
|
374
|
+
for (const p of plan) {
|
|
375
|
+
console.log(`y${String(p.y).padEnd(6)} ${p.ref.padEnd(36)} ${p.skip ? `SKIP (${p.skip})` : p.js ? '(script.js)' : ''}`);
|
|
376
|
+
console.log(` clone: ${p.clone.slice(0, 110)}`);
|
|
377
|
+
if (p.parts) p.parts.forEach((x) => console.log(` -> ${x.to.slice(0, 110)}`));
|
|
378
|
+
}
|
|
379
|
+
console.log(`\n${plan.filter((p) => p.parts).length} blocks reshaped, ${plan.filter((p) => p.skip).length} skipped`);
|
|
380
|
+
if (flags.write) {
|
|
381
|
+
await fs.writeFile(htmlFile, out);
|
|
382
|
+
if (outJs !== js) await fs.writeFile(jsFile, outJs);
|
|
383
|
+
console.log(`wrote ${path.relative(process.cwd(), htmlFile)}${outJs !== js ? ' and script.js' : ''} (${changed} text parts)`);
|
|
384
|
+
} else console.log('dry run: add --write to apply');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Cut a tall full-page screenshot into viewport-sized tiles you can actually read.
|
|
2
|
+
// usage: node scripts/tiles.mjs <png> [tileHeight=1200] -> <png-dir>/tiles/<name>-01.png ...
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { PNG } from 'pngjs';
|
|
6
|
+
|
|
7
|
+
const [file, h = '1200'] = process.argv.slice(2);
|
|
8
|
+
if (!file) {
|
|
9
|
+
console.error('usage: npm run tiles -- <png> [tileHeight]');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
const tileH = Number(h);
|
|
13
|
+
const src = PNG.sync.read(await fs.readFile(file));
|
|
14
|
+
const dir = path.join(path.dirname(file), 'tiles');
|
|
15
|
+
await fs.mkdir(dir, { recursive: true });
|
|
16
|
+
const base = path.basename(file, '.png');
|
|
17
|
+
let n = 0;
|
|
18
|
+
for (let y = 0; y < src.height; y += tileH) {
|
|
19
|
+
const th = Math.min(tileH, src.height - y);
|
|
20
|
+
const out = new PNG({ width: src.width, height: th });
|
|
21
|
+
PNG.bitblt(src, out, 0, y, src.width, th, 0, 0);
|
|
22
|
+
const name = `${base}-${String(++n).padStart(2, '0')}.png`;
|
|
23
|
+
await fs.writeFile(path.join(dir, name), PNG.sync.write(out));
|
|
24
|
+
}
|
|
25
|
+
console.log(`${n} tiles -> ${dir}`);
|