autokap 1.1.7 → 1.1.8

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.
@@ -6,6 +6,16 @@ export const CHROMIUM_ARGS = [
6
6
  '--no-sandbox',
7
7
  '--disable-setuid-sandbox',
8
8
  '--disable-dev-shm-usage',
9
+ // Disable font hinting to match macOS Core Text subpixel positioning.
10
+ // Linux Chromium defaults to "medium" hinting which pixel-snaps glyph
11
+ // advance widths — this widens text horizontally and shifts wrap points
12
+ // vs a Mac developer's local view. Validated via Docker repro on
13
+ // autokap.app: with default hinting "Vos screenshots en autopilote"
14
+ // wraps as "...screenshots en / autopilote", on Mac and with
15
+ // hinting=none it wraps as "...screenshots / en autopilote" (identical
16
+ // to local). Trade-off: minor blurriness at <12px sizes; acceptable for
17
+ // marketing screenshots where layout fidelity matters more.
18
+ '--font-render-hinting=none',
9
19
  ] : []),
10
20
  // No GPU on headless servers
11
21
  '--disable-gpu',
package/dist/browser.js CHANGED
@@ -120,6 +120,40 @@ async function withHelperTimeout(label, timeoutMs, work) {
120
120
  function isLikelyFontUrl(url) {
121
121
  return /\.(?:woff2?|ttf|otf)(?:[?#]|$)/i.test(url);
122
122
  }
123
+ /**
124
+ * Strip C0/C1 control characters (except TAB/LF/CR) and DEL from text nodes
125
+ * before screenshot. macOS Core Text renders these as zero-width/invisible by
126
+ * default; Linux Skia + most webfonts render them as `.notdef` tofu boxes
127
+ * because the font's unicode-range claims coverage of U+0000-007F but the
128
+ * actual font file has no glyph for control characters. Examples seen in
129
+ * customer sites: `\x1B[32m...\x1B[0m` ANSI escapes embedded in mock terminal
130
+ * output, BOM-like markers in CMS-pasted text, etc.
131
+ *
132
+ * This normalizes Linux capture output to match what a developer sees on
133
+ * their Mac. Idempotent — safe to run once per screenshot.
134
+ *
135
+ * Preserves: U+0009 TAB, U+000A LF, U+000D CR (semantic whitespace).
136
+ * Strips: U+0000-0008, U+000B, U+000C, U+000E-001F, U+007F (DEL), U+0080-009F.
137
+ */
138
+ async function stripInvisibleControlChars(page) {
139
+ await page.evaluate(() => {
140
+ const RE = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F]', 'g');
141
+ const walk = (node) => {
142
+ if (node.nodeType === 3) {
143
+ const original = node.nodeValue ?? '';
144
+ if (original.length > 0 && RE.test(original)) {
145
+ node.nodeValue = original.replace(RE, '');
146
+ }
147
+ return;
148
+ }
149
+ if (node.nodeType !== 1)
150
+ return;
151
+ for (const child of Array.from(node.childNodes))
152
+ walk(child);
153
+ };
154
+ walk(document.body);
155
+ }).catch(() => { });
156
+ }
123
157
  /**
124
158
  * In-page metric probe gate: measure the width of a sample string rendered with
125
159
  * the inherited font-family vs a deliberately-divergent baseline (monospace by
@@ -327,45 +361,109 @@ async function logFontDiagnostics(page, stage) {
327
361
  }
328
362
  async function collectPlatformFontDiagnostics(page) {
329
363
  try {
364
+ // Step 1: in-page, find diverse visible text-bearing elements and compute
365
+ // a path-based CSS selector that uniquely identifies each. We sample
366
+ // across many tag types so badges/pills/labels (where font bugs hide) are
367
+ // covered — the previous version only captured h1/h2/p/button/a/body and
368
+ // missed all <span> content like the AssistantBadges in the hero.
369
+ const candidates = await page.evaluate(() => {
370
+ const computeSelector = (el) => {
371
+ const parts = [];
372
+ let cur = el;
373
+ let safety = 8;
374
+ while (cur && cur.tagName !== 'BODY' && cur.tagName !== 'HTML' && safety-- > 0) {
375
+ const tag = cur.tagName.toLowerCase();
376
+ const parent = cur.parentElement;
377
+ if (!parent)
378
+ break;
379
+ const sameTagSiblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
380
+ if (sameTagSiblings.length === 1) {
381
+ parts.unshift(tag);
382
+ }
383
+ else {
384
+ const idx = sameTagSiblings.indexOf(cur) + 1;
385
+ parts.unshift(`${tag}:nth-of-type(${idx})`);
386
+ }
387
+ cur = parent;
388
+ }
389
+ return ['body', ...parts].join(' > ');
390
+ };
391
+ const TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'button', 'label', 'li', 'td', 'span', 'code', 'pre', 'kbd', 'small'];
392
+ const out = [];
393
+ const seen = new Set();
394
+ for (const tag of TAGS) {
395
+ if (out.length >= 25)
396
+ break;
397
+ const elements = Array.from(document.querySelectorAll(tag));
398
+ let perTagCount = 0;
399
+ for (const el of elements) {
400
+ if (perTagCount >= 3 || out.length >= 25)
401
+ break;
402
+ const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
403
+ if (text.length < 3)
404
+ continue;
405
+ const rect = el.getBoundingClientRect();
406
+ if (rect.width < 4 || rect.height < 4)
407
+ continue;
408
+ const style = getComputedStyle(el);
409
+ if (style.visibility === 'hidden' || style.display === 'none')
410
+ continue;
411
+ const selector = computeSelector(el);
412
+ if (seen.has(selector))
413
+ continue;
414
+ seen.add(selector);
415
+ out.push({
416
+ selector,
417
+ tag,
418
+ classes: el.className.toString().slice(0, 120),
419
+ text: text.slice(0, 60),
420
+ fontFamily: style.fontFamily,
421
+ fontSize: style.fontSize,
422
+ fontWeight: style.fontWeight,
423
+ });
424
+ perTagCount++;
425
+ }
426
+ }
427
+ return out;
428
+ });
330
429
  const client = await page.context().newCDPSession(page);
331
430
  try {
332
431
  await client.send('DOM.enable');
333
432
  await client.send('CSS.enable');
334
- const documentResponse = await client.send('DOM.getDocument', { depth: 1, pierce: true });
335
- const selectors = [
336
- 'body',
337
- 'h1',
338
- 'h2',
339
- 'p',
340
- 'button',
341
- 'a',
342
- '[data-ak]',
343
- 'span',
344
- ];
433
+ const documentResponse = await client.send('DOM.getDocument', { depth: -1, pierce: true });
345
434
  const diagnostics = [];
346
- for (const selector of selectors) {
347
- const queryResponse = await client.send('DOM.querySelector', {
348
- nodeId: documentResponse.root.nodeId,
349
- selector,
350
- });
351
- if (!queryResponse.nodeId)
352
- continue;
353
- const text = await page.locator(selector).first().evaluate((node) => (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 80)).catch(() => '');
354
- const fontsResponse = await client.send('CSS.getPlatformFontsForNode', {
355
- nodeId: queryResponse.nodeId,
356
- });
357
- diagnostics.push({
358
- selector,
359
- text,
360
- fonts: (fontsResponse.fonts ?? []).map((font) => ({
361
- familyName: font.familyName,
362
- postScriptName: font.postScriptName,
363
- isCustomFont: font.isCustomFont,
364
- glyphCount: font.glyphCount,
365
- })),
366
- });
367
- if (diagnostics.length >= 6)
435
+ for (const candidate of candidates) {
436
+ if (diagnostics.length >= 20)
368
437
  break;
438
+ try {
439
+ const queryResponse = await client.send('DOM.querySelector', {
440
+ nodeId: documentResponse.root.nodeId,
441
+ selector: candidate.selector,
442
+ });
443
+ if (!queryResponse.nodeId)
444
+ continue;
445
+ const fontsResponse = await client.send('CSS.getPlatformFontsForNode', {
446
+ nodeId: queryResponse.nodeId,
447
+ });
448
+ diagnostics.push({
449
+ selector: candidate.selector,
450
+ tag: candidate.tag,
451
+ classes: candidate.classes,
452
+ text: candidate.text,
453
+ fontFamily: candidate.fontFamily,
454
+ fontSize: candidate.fontSize,
455
+ fontWeight: candidate.fontWeight,
456
+ fonts: (fontsResponse.fonts ?? []).map((font) => ({
457
+ familyName: font.familyName,
458
+ postScriptName: font.postScriptName,
459
+ isCustomFont: font.isCustomFont,
460
+ glyphCount: font.glyphCount,
461
+ })),
462
+ });
463
+ }
464
+ catch {
465
+ // Skip nodes that fail to resolve — page may be re-rendering.
466
+ }
369
467
  }
370
468
  return diagnostics;
371
469
  }
@@ -1086,6 +1184,11 @@ export class Browser {
1086
1184
  await page.mouse.move(0, 0);
1087
1185
  await ensureCaptureHideStyles(page);
1088
1186
  await this.waitForFontsBeforeScreenshot(page);
1187
+ // Strip C0/C1 control chars from text nodes — Linux Skia renders them as
1188
+ // .notdef tofu, macOS Core Text renders them invisibly. Normalize Linux
1189
+ // capture to match Mac (validated via Docker repro on autokap.app's
1190
+ // terminal block where ANSI escape literals were producing tofu boxes).
1191
+ await stripInvisibleControlChars(page);
1089
1192
  await logFontDiagnostics(page, 'before screenshot');
1090
1193
  const screenshot = Buffer.from(await page.screenshot({ type: 'png', fullPage: false }));
1091
1194
  await logCaptureRenderDiagnostics(page, screenshot, this.options, 'after screenshot');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "description": "AI-powered CLI tool for capturing clean screenshots of websites",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",