Skip to content

Commit 7132cbe

Browse files
committed
Fix PDF export wrapping
1 parent 80b7efc commit 7132cbe

1 file changed

Lines changed: 54 additions & 63 deletions

File tree

‎scripts/export_canvas_guard_playwright.js‎

Lines changed: 54 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
#!/usr/bin/env node
2-
32
const fs = require("fs");
43
const http = require("http");
54
const path = require("path");
65
const { chromium } = require("playwright");
7-
86
const ROOT = path.resolve(__dirname, "..");
97
const ARTIFACT_DIR = path.join(ROOT, "data", "test_reports", "artifacts");
10-
118
const MIME = {
129
".html": "text/html; charset=utf-8",
1310
".css": "text/css; charset=utf-8",
@@ -20,14 +17,12 @@ const MIME = {
2017
".pdf": "application/pdf",
2118
".txt": "text/plain; charset=utf-8",
2219
};
23-
2420
function startStaticServer(rootDir) {
2521
const server = http.createServer((req, res) => {
2622
try {
2723
const reqPath = decodeURIComponent((req.url || "/").split("?")[0]);
2824
const normalized = path.normalize(reqPath).replace(/^\/+/, "");
2925
let filePath = path.join(rootDir, normalized || "index.html");
30-
3126
if (!filePath.startsWith(rootDir)) {
3227
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
3328
res.end("Forbidden");
@@ -41,7 +36,6 @@ function startStaticServer(rootDir) {
4136
res.end("Not found");
4237
return;
4338
}
44-
4539
const ext = path.extname(filePath).toLowerCase();
4640
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
4741
fs.createReadStream(filePath).pipe(res);
@@ -50,15 +44,13 @@ function startStaticServer(rootDir) {
5044
res.end(`Server error: ${error.message}`);
5145
}
5246
});
53-
5447
return new Promise((resolve, reject) => {
5548
server.on("error", reject);
5649
server.listen(0, "127.0.0.1", () => {
5750
resolve({ server, port: server.address().port });
5851
});
5952
});
6053
}
61-
6254
function toFilePathFromDataUrl(dataUrl, targetPath) {
6355
const marker = "base64,";
6456
const index = String(dataUrl || "").indexOf(marker);
@@ -67,19 +59,16 @@ function toFilePathFromDataUrl(dataUrl, targetPath) {
6759
}
6860
fs.writeFileSync(targetPath, Buffer.from(dataUrl.slice(index + marker.length), "base64"));
6961
}
70-
7162
async function collectCanvasProbe(page) {
7263
return page.evaluate(async () => {
7364
const pages = getNonEmptyPageElements();
7465
const results = [];
75-
7666
for (let idx = 0; idx < pages.length; idx += 1) {
7767
const canvas = await renderExportPageToCanvas(pages[idx]);
7868
const ctx = canvas.getContext("2d", { willReadFrequently: true });
7969
if (!ctx) {
8070
throw new Error("Canvas 2D context unavailable for export probe.");
8171
}
82-
8372
const w = canvas.width;
8473
const h = canvas.height;
8574
const step = Math.max(1, Math.floor(Math.min(w, h) / 700));
@@ -90,7 +79,6 @@ async function collectCanvasProbe(page) {
9079
let maxX = -1;
9180
let maxY = -1;
9281
const bottomStart = Math.floor(h * 0.8);
93-
9482
for (let y = 0; y < h; y += step) {
9583
for (let x = 0; x < w; x += step) {
9684
const [r, g, b, a] = ctx.getImageData(x, y, 1, 1).data;
@@ -104,7 +92,6 @@ async function collectCanvasProbe(page) {
10492
if (y > maxY) maxY = y;
10593
}
10694
}
107-
10895
const sampled = Math.max(1, Math.ceil(w / step) * Math.ceil(h / step));
10996
const bboxHeightRatio = maxY >= 0 ? (maxY - minY + 1) / Math.max(1, h) : 0;
11097
const bottomInkRatio = ink > 0 ? bottomInk / ink : 0;
@@ -121,7 +108,6 @@ async function collectCanvasProbe(page) {
121108
dataUrl: canvas.toDataURL("image/png"),
122109
});
123110
}
124-
125111
const minBBoxHeightRatio = results.length ? Math.min(...results.map((item) => item.bboxHeightRatio)) : 0;
126112
const minBottomInkRatio = results.length ? Math.min(...results.map((item) => item.bottomInkRatio)) : 0;
127113
return {
@@ -132,15 +118,13 @@ async function collectCanvasProbe(page) {
132118
};
133119
});
134120
}
135-
136121
async function collectInlineWrapProbe(page) {
137122
return page.evaluate(async () => {
138123
const pageEl = refs.page1Content?.parentElement;
139124
const pageContent = refs.page1Content;
140125
if (!pageEl || !pageContent) {
141126
throw new Error("Page 1 element unavailable for inline-wrap probe.");
142127
}
143-
144128
const wrapCases = [
145129
{
146130
id: "short_dictionary",
@@ -167,7 +151,6 @@ async function collectInlineWrapProbe(page) {
167151
minCodeLines: 2,
168152
},
169153
];
170-
171154
const host = document.createElement("article");
172155
host.id = "exportInlineWrapProbeHost";
173156
host.className = "preview-card";
@@ -182,7 +165,6 @@ async function collectInlineWrapProbe(page) {
182165
pointerEvents: "none",
183166
borderRadius: "8px",
184167
});
185-
186168
const body = document.createElement("div");
187169
body.className = "preview-body";
188170
body.style.paddingTop = "4px";
@@ -201,14 +183,12 @@ async function collectInlineWrapProbe(page) {
201183
.join("");
202184
host.appendChild(body);
203185
pageContent.appendChild(host);
204-
205186
try {
206187
const caseEls = Array.from(host.querySelectorAll(".export-inline-wrap-case"));
207188
const codeEl = host.querySelector("code.inline-code");
208189
if (!caseEls.length || !codeEl) {
209190
throw new Error("Inline-wrap probe host did not render expected elements.");
210191
}
211-
212192
const readInlineStyle = () => {
213193
const style = window.getComputedStyle(codeEl);
214194
return {
@@ -255,14 +235,12 @@ async function collectInlineWrapProbe(page) {
255235
.filter((key) => norm(normalStyle[key]) !== norm(exportStyle[key]))
256236
.map((key) => ({ key, normal: normalStyle[key], export: exportStyle[key] }));
257237
const styleParityOk = styleMismatches.length === 0;
258-
259238
const pageRect = pageEl.getBoundingClientRect();
260239
const canvas = await renderExportPageToCanvas(pageEl, { scale: 2 });
261240
const ctx = canvas.getContext("2d", { willReadFrequently: true });
262241
if (!ctx) {
263242
throw new Error("Canvas 2D context unavailable for inline-wrap probe.");
264243
}
265-
266244
const scaleX = canvas.width / Math.max(1, pageRect.width);
267245
const scaleY = canvas.height / Math.max(1, pageRect.height);
268246
const toRelative = (rect) => ({
@@ -271,7 +249,6 @@ async function collectInlineWrapProbe(page) {
271249
w: rect.width,
272250
h: rect.height,
273251
});
274-
275252
const sampleRect = (rect, padX = 0, padY = 0) => {
276253
const relX = Math.max(0, rect.x - padX);
277254
const relY = Math.max(0, rect.y - padY);
@@ -282,42 +259,73 @@ async function collectInlineWrapProbe(page) {
282259
const rw = Math.max(6, Math.floor(relW * scaleX));
283260
const rh = Math.max(6, Math.floor(relH * scaleY));
284261
const step = Math.max(1, Math.floor(Math.min(rw, rh) / 80));
285-
262+
const image = ctx.getImageData(rx, ry, rw, rh).data;
286263
let sampled = 0;
287264
let ink = 0;
288265
let tint = 0;
289266
let minInkX = rw;
290-
let maxInkX = -1;
291-
292-
const maxX = Math.min(canvas.width, rx + rw);
293-
const maxY = Math.min(canvas.height, ry + rh);
294-
for (let py = ry; py < maxY; py += step) {
295-
for (let px = rx; px < maxX; px += step) {
267+
let sawInk = false;
268+
for (let localY = 0; localY < rh; localY += step) {
269+
for (let localX = 0; localX < rw; localX += step) {
270+
const idx = (localY * rw + localX) * 4;
296271
sampled += 1;
297-
const [r, g, b, a] = ctx.getImageData(px, py, 1, 1).data;
272+
const r = image[idx];
273+
const g = image[idx + 1];
274+
const b = image[idx + 2];
275+
const a = image[idx + 3];
298276
const isInk = a > 8 && (r < 246 || g < 246 || b < 246);
299277
if (isInk) {
300278
ink += 1;
301-
const localX = px - rx;
279+
sawInk = true;
302280
if (localX < minInkX) minInkX = localX;
303-
if (localX > maxInkX) maxInkX = localX;
304281
}
305282
const spread = Math.max(r, g, b) - Math.min(r, g, b);
306283
const isCodeTint = a > 8 && r >= 220 && g >= 220 && b >= 220 && r <= 252 && g <= 252 && b <= 252 && spread <= 18;
307284
if (isCodeTint) tint += 1;
308285
}
309286
}
310-
311287
const denom = Math.max(1, sampled);
312288
return {
313289
sampled: denom,
314290
inkRatio: ink / denom,
315291
tintRatio: tint / denom,
316-
minInkXRatio: maxInkX >= 0 ? minInkX / Math.max(1, rw) : 1,
292+
minInkXRatio: sawInk ? minInkX / Math.max(1, rw) : 1,
317293
canvasBox: { x: rx, y: ry, w: rw, h: rh },
318294
};
319295
};
320-
296+
const sampleOutsideTintRatio = (paragraphBox, codeBox) => {
297+
const px = Math.max(0, Math.floor(paragraphBox.x));
298+
const py = Math.max(0, Math.floor(paragraphBox.y));
299+
const pw = Math.max(6, Math.floor(paragraphBox.w));
300+
const ph = Math.max(6, Math.floor(paragraphBox.h));
301+
const step = Math.max(1, Math.floor(Math.min(pw, ph) / 80));
302+
const image = ctx.getImageData(px, py, pw, ph).data;
303+
const codeLeft = Math.floor(codeBox.x);
304+
const codeTop = Math.floor(codeBox.y);
305+
const codeRight = codeLeft + Math.floor(codeBox.w);
306+
const codeBottom = codeTop + Math.floor(codeBox.h);
307+
let sampledOutside = 0;
308+
let tintedOutside = 0;
309+
for (let localY = 0; localY < ph; localY += step) {
310+
const globalY = py + localY;
311+
for (let localX = 0; localX < pw; localX += step) {
312+
const globalX = px + localX;
313+
const inCode = globalX >= codeLeft && globalX < codeRight && globalY >= codeTop && globalY < codeBottom;
314+
if (inCode) continue;
315+
sampledOutside += 1;
316+
const idx = (localY * pw + localX) * 4;
317+
const r = image[idx];
318+
const g = image[idx + 1];
319+
const b = image[idx + 2];
320+
const a = image[idx + 3];
321+
const spread = Math.max(r, g, b) - Math.min(r, g, b);
322+
const isCodeTint = a > 8 && r >= 220 && g >= 220 && b >= 220 && r <= 252 && g <= 252 && b <= 252 && spread <= 18;
323+
if (isCodeTint) tintedOutside += 1;
324+
}
325+
}
326+
if (sampledOutside < 1) return 0;
327+
return tintedOutside / sampledOutside;
328+
};
321329
const caseResults = caseEls.map((element) => {
322330
const caseId = element.getAttribute("data-case-id") || "";
323331
const config = wrapCases.find((item) => item.id === caseId) || wrapCases[0];
@@ -327,24 +335,22 @@ async function collectInlineWrapProbe(page) {
327335
if (!code || !prefix || !suffix) {
328336
return { id: caseId, ok: false, error: "Expected probe spans are missing." };
329337
}
330-
331338
const range = document.createRange();
332339
range.selectNodeContents(code);
333340
const lineRectsRaw = Array.from(range.getClientRects()).filter((lineRect) => lineRect.width > 1 && lineRect.height > 1);
334341
const lineRects = (lineRectsRaw.length ? lineRectsRaw : [code.getBoundingClientRect()]).map(toRelative);
335-
336-
const codeMetrics = sampleRect(toRelative(code.getBoundingClientRect()), 2, 2);
337-
const prefixMetrics = sampleRect(toRelative(prefix.getBoundingClientRect()), 1, 1);
338-
const suffixMetrics = sampleRect(toRelative(suffix.getBoundingClientRect()), 1, 1);
339-
const paragraphMetrics = sampleRect(toRelative(element.getBoundingClientRect()), 1, 1);
340-
341-
const outsideSamples = Math.max(1, paragraphMetrics.sampled - codeMetrics.sampled);
342-
const outsideTint = Math.max(0, paragraphMetrics.tintRatio * paragraphMetrics.sampled - codeMetrics.tintRatio * codeMetrics.sampled);
343-
const outsideTintRatio = outsideTint / outsideSamples;
342+
const codeRect = toRelative(code.getBoundingClientRect());
343+
const prefixRect = toRelative(prefix.getBoundingClientRect());
344+
const suffixRect = toRelative(suffix.getBoundingClientRect());
345+
const paragraphRect = toRelative(element.getBoundingClientRect());
346+
const codeMetrics = sampleRect(codeRect, 2, 2);
347+
const prefixMetrics = sampleRect(prefixRect, 1, 1);
348+
const suffixMetrics = sampleRect(suffixRect, 1, 1);
349+
const paragraphMetrics = sampleRect(paragraphRect, 1, 1);
350+
const outsideTintRatio = sampleOutsideTintRatio(paragraphMetrics.canvasBox, codeMetrics.canvasBox);
344351
const minStarts = lineRects.map((lineRect) => sampleRect(lineRect, 1, 1).minInkXRatio);
345352
const firstLineStart = minStarts[0] ?? 1;
346353
const lineStartDrift = minStarts.length > 1 ? Math.max(...minStarts) - Math.min(...minStarts) : 0;
347-
348354
const hasExpectedWrap = lineRects.length >= (config.minCodeLines || 1);
349355
const ok =
350356
hasExpectedWrap &&
@@ -354,7 +360,6 @@ async function collectInlineWrapProbe(page) {
354360
outsideTintRatio <= 0.45 &&
355361
(lineRects.length < 2 || firstLineStart <= 0.6) &&
356362
(lineRects.length < 2 || lineStartDrift <= 0.55);
357-
358363
return {
359364
id: caseId,
360365
ok,
@@ -366,18 +371,16 @@ async function collectInlineWrapProbe(page) {
366371
outsideTintRatio: Number(outsideTintRatio.toFixed(5)),
367372
firstLineStartRatio: Number(firstLineStart.toFixed(5)),
368373
maxLineStartDrift: Number(lineStartDrift.toFixed(5)),
369-
paragraphCanvasBox: codeMetrics.canvasBox,
374+
paragraphCanvasBox: paragraphMetrics.canvasBox,
370375
};
371376
});
372-
373377
const wrapCasesOk = caseResults.every((item) => item.ok);
374378
const ok = styleParityOk && wrapCasesOk;
375379
const minCodeInkRatio = caseResults.length ? Math.min(...caseResults.map((item) => item.codeInkRatio || 0)) : 0;
376380
const minPrefixInkRatio = caseResults.length ? Math.min(...caseResults.map((item) => item.prefixInkRatio || 0)) : 0;
377381
const minSuffixInkRatio = caseResults.length ? Math.min(...caseResults.map((item) => item.suffixInkRatio || 0)) : 0;
378382
const maxFirstLineStartRatio = caseResults.length ? Math.max(...caseResults.map((item) => item.firstLineStartRatio || 0)) : 0;
379383
const maxLineStartDrift = caseResults.length ? Math.max(...caseResults.map((item) => item.maxLineStartDrift || 0)) : 0;
380-
381384
return {
382385
ok,
383386
styleParityOk,
@@ -407,28 +410,23 @@ async function collectInlineWrapProbe(page) {
407410
}
408411
});
409412
}
410-
411413
async function run() {
412414
fs.mkdirSync(ARTIFACT_DIR, { recursive: true });
413415
const { server, port } = await startStaticServer(ROOT);
414416
const url = `http://127.0.0.1:${port}/index.html`;
415-
416417
try {
417418
const browser = await chromium.launch({ headless: true });
418419
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
419420
const page = await context.newPage();
420-
421421
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
422422
await page.waitForSelector("#cardHost", { timeout: 25000 });
423423
await page.waitForFunction(
424424
() => !document.querySelector("#cardHost")?.textContent?.includes("Loading topic cards..."),
425425
{ timeout: 30000 }
426426
);
427-
428427
if ((await page.locator("#splashOverlay:not(.hidden)").count()) > 0) {
429428
await page.click("#getStartedBtn", { timeout: 5000 });
430429
}
431-
432430
const selectableItems = page.locator("#cardHost [data-role='item-toggle']");
433431
if ((await selectableItems.count()) > 0) {
434432
await selectableItems.first().click({ timeout: 5000 });
@@ -437,34 +435,28 @@ async function run() {
437435
await page.waitForTimeout(350);
438436
await page.click("#goToPreviewBtn", { timeout: 5000 });
439437
await page.waitForSelector("#previewView.active", { timeout: 10000 });
440-
441438
if ((await page.locator(".preview-card").count()) < 1) {
442439
throw new Error("No preview cards available for export canvas guard.");
443440
}
444-
445441
const probe = await collectCanvasProbe(page);
446442
const inlineWrapProbe = await collectInlineWrapProbe(page);
447443
const artifactPaths = [];
448-
449444
for (const item of probe.pages) {
450445
const artifactPath = path.join(ARTIFACT_DIR, `export-canvas-page-${item.page}.png`);
451446
toFilePathFromDataUrl(item.dataUrl, artifactPath);
452447
artifactPaths.push(artifactPath);
453448
delete item.dataUrl;
454449
item.artifactPath = artifactPath;
455450
}
456-
457451
const inlineWrapArtifactPath = path.join(ARTIFACT_DIR, "export-inline-wrap-probe.png");
458452
toFilePathFromDataUrl(inlineWrapProbe.artifactDataUrl, inlineWrapArtifactPath);
459453
delete inlineWrapProbe.artifactDataUrl;
460454
inlineWrapProbe.artifactPath = inlineWrapArtifactPath;
461-
462455
const ok =
463456
probe.pagesDetected >= 1 &&
464457
probe.minBBoxHeightRatio >= 0.55 &&
465458
probe.minBottomInkRatio >= 0.02 &&
466459
inlineWrapProbe.ok;
467-
468460
await browser.close();
469461
console.log(
470462
JSON.stringify(
@@ -489,7 +481,6 @@ async function run() {
489481
server.close();
490482
}
491483
}
492-
493484
run().catch((error) => {
494485
console.error(error.stack || error.message);
495486
process.exit(1);

0 commit comments

Comments
 (0)