@smallpen/local-package 0.1.0-alpha.2 → 0.1.0-alpha.5

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/src/render.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  SmallPenError,
12
12
  } from "@smallpen/core";
13
13
 
14
- import { initWebpDecoder, decodeWebpSync } from "./webp-decode.mjs";
14
+ import { decodeWebpSync, initWebpDecoder, webpDimensions } from "./webp-decode.mjs";
15
15
 
16
16
  const MAX_DIMENSION = 8192;
17
17
  const MAX_PIXELS = 32 * 1024 * 1024;
@@ -59,8 +59,27 @@ function parseHexColor(value) {
59
59
  ];
60
60
  }
61
61
 
62
+ const parsedColors = new WeakMap();
63
+
64
+ // Paints are sampled per pixel, so each color is parsed, and an unsupported
65
+ // one reported, once per diagnostics list rather than once per pixel.
62
66
  function parseColor(value, diagnostics, path) {
63
67
  if (typeof value !== "string") return [255, 0, 255, 255];
68
+ let cache = parsedColors.get(diagnostics);
69
+ if (!cache) {
70
+ cache = new Map();
71
+ parsedColors.set(diagnostics, cache);
72
+ }
73
+ const key = `${path}\0${value}`;
74
+ let color = cache.get(key);
75
+ if (!color) {
76
+ color = parseColorValue(value, diagnostics, path);
77
+ cache.set(key, color);
78
+ }
79
+ return [...color];
80
+ }
81
+
82
+ function parseColorValue(value, diagnostics, path) {
64
83
  const source = value.trim().toLowerCase();
65
84
  if (source.startsWith("#")) {
66
85
  const parsed = parseHexColor(source);
@@ -327,9 +346,11 @@ function resolveFont(state, style, nodeId, path) {
327
346
  const key = `${nodeId}:${id || family}`;
328
347
  if (!state.fontFallbacks.has(key)) {
329
348
  state.fontFallbacks.add(key);
349
+ const requestedFont = style.fontFamily ?? style.fontId ?? "unknown font";
330
350
  state.diagnostics.push({
331
351
  code: "font_render_fallback",
332
- message: `Renderer substituted Source Sans Pro for ${style.fontFamily ?? style.fontId ?? "unknown font"}`,
352
+ details: { requestedFont, substituteFont: "Source Sans Pro" },
353
+ message: `Renderer substituted Source Sans Pro for ${requestedFont}`,
333
354
  nodeId,
334
355
  path,
335
356
  });
@@ -995,10 +1016,11 @@ function lineGlyphs(
995
1016
  baseline,
996
1017
  size,
997
1018
  letterSpacing,
998
- outlines = true,
1019
+ onGlyph,
999
1020
  missing,
1021
+ start,
1000
1022
  ) {
1001
- const glyphs = [...text].map((character) => {
1023
+ const resolveGlyph = (character) => {
1002
1024
  if (WHITESPACE.test(character)) {
1003
1025
  // Whitespace never draws ink, even when the font lacks the codepoint.
1004
1026
  return {
@@ -1026,41 +1048,46 @@ function lineGlyphs(
1026
1048
  };
1027
1049
  }
1028
1050
  return { font, glyph };
1029
- });
1030
- const result = [];
1031
- let cursor = x;
1032
- for (let index = 0; index < glyphs.length; index += 1) {
1033
- const { advanceWidth, font: glyphFont, glyph, missing: isMissing, whitespace } = glyphs[index];
1034
- if (index > 0 && glyphs[index - 1].font === glyphFont) {
1051
+ };
1052
+ // `start` continues a previous result, so a line can be measured run by run.
1053
+ let cursor = start?.cursor ?? x;
1054
+ let previous = start?.previous;
1055
+ for (const character of text) {
1056
+ const entry = resolveGlyph(character);
1057
+ const { advanceWidth, font: glyphFont, glyph, missing: isMissing, whitespace } = entry;
1058
+ if (previous?.font === glyphFont) {
1035
1059
  cursor +=
1036
- (glyphFont.getKerningValue(glyphs[index - 1].glyph, glyph) * size) /
1060
+ (glyphFont.getKerningValue(previous.glyph, glyph) * size) /
1037
1061
  glyphFont.unitsPerEm;
1038
1062
  }
1039
- if (outlines && !isMissing && !whitespace) {
1040
- const contours = flattenPath(glyph.getPath(cursor, baseline, size));
1041
- if (contours.length > 0) result.push({ bounds: contourBounds(contours), contours });
1042
- }
1063
+ if (onGlyph && !isMissing && !whitespace) onGlyph(glyph, cursor);
1043
1064
  cursor +=
1044
1065
  ((advanceWidth ?? glyph.advanceWidth ?? glyphFont.unitsPerEm) * size) /
1045
1066
  glyphFont.unitsPerEm +
1046
1067
  letterSpacing;
1068
+ previous = entry;
1047
1069
  }
1048
- return { glyphs: result, width: Math.max(0, cursor - x - letterSpacing) };
1070
+ return { cursor, previous, width: Math.max(0, cursor - x - letterSpacing) };
1049
1071
  }
1050
1072
 
1073
+ // `measure(run, previous)` returns the width of the line so far plus `run`,
1074
+ // continuing from the result for the line so far.
1051
1075
  function wrapTextLines(source, width, measure) {
1052
1076
  const lines = [];
1053
1077
  for (const paragraph of source.split("\n")) {
1054
1078
  let line = "";
1079
+ let measured;
1055
1080
  // Preserve spaces and tabs, allowing a break after each (break-spaces).
1056
1081
  // Runs without these break opportunities keep their existing overflow.
1057
1082
  for (const run of paragraph.split(/(?<=[ \t])/u)) {
1058
- const candidate = line + run;
1059
- if (line && measure(candidate) > width) {
1083
+ const candidate = measure(run, measured);
1084
+ if (line && candidate.width > width) {
1060
1085
  lines.push(line);
1061
1086
  line = run;
1087
+ measured = measure(run);
1062
1088
  } else {
1063
- line = candidate;
1089
+ line += run;
1090
+ measured = candidate;
1064
1091
  }
1065
1092
  }
1066
1093
  lines.push(line);
@@ -1124,6 +1151,68 @@ function renderGlyph(
1124
1151
  }
1125
1152
  }
1126
1153
 
1154
+ const glyphUnitBoxes = new WeakMap();
1155
+
1156
+ // Outlines are flattened only for glyphs whose exact curve bounds can reach
1157
+ // both the node box and the canvas, and are dropped right after drawing, so
1158
+ // long text costs memory per visible glyph rather than per character.
1159
+ function renderGlyphOutline(
1160
+ state,
1161
+ node,
1162
+ matrix,
1163
+ inverseMatrix,
1164
+ glyph,
1165
+ x,
1166
+ baseline,
1167
+ size,
1168
+ color,
1169
+ opacity,
1170
+ clips,
1171
+ ) {
1172
+ let box = glyphUnitBoxes.get(glyph);
1173
+ if (!box) {
1174
+ box = glyph.getBoundingBox();
1175
+ glyphUnitBoxes.set(glyph, box);
1176
+ }
1177
+ // Same scale as Glyph.getPath; one unit of margin absorbs rounding.
1178
+ const scale = (1 / (glyph.path.unitsPerEm || 1000)) * size;
1179
+ const local = {
1180
+ bottom: baseline - box.y1 * scale + 1,
1181
+ left: x + box.x1 * scale - 1,
1182
+ right: x + box.x2 * scale + 1,
1183
+ top: baseline - box.y2 * scale - 1,
1184
+ };
1185
+ if (
1186
+ local.right < 0 ||
1187
+ local.bottom < 0 ||
1188
+ local.left > node.width ||
1189
+ local.top > node.height
1190
+ ) {
1191
+ return;
1192
+ }
1193
+ const device = transformedRectangleBounds(matrix, local);
1194
+ if (
1195
+ device.right * state.scale < 0 ||
1196
+ device.bottom * state.scale < 0 ||
1197
+ device.left * state.scale > state.width ||
1198
+ device.top * state.scale > state.height
1199
+ ) {
1200
+ return;
1201
+ }
1202
+ const contours = flattenPath(glyph.getPath(x, baseline, size));
1203
+ if (contours.length === 0) return;
1204
+ renderGlyph(
1205
+ state,
1206
+ node,
1207
+ matrix,
1208
+ inverseMatrix,
1209
+ { bounds: contourBounds(contours), contours },
1210
+ color,
1211
+ opacity,
1212
+ clips,
1213
+ );
1214
+ }
1215
+
1127
1216
  function renderTextDecoration(
1128
1217
  state,
1129
1218
  node,
@@ -1239,6 +1328,28 @@ function richLineLayout(characters) {
1239
1328
  return { placed, width: Math.max(0, width) };
1240
1329
  }
1241
1330
 
1331
+ // Continues richLineLayout's width sum, so wrapping a word costs its own
1332
+ // length instead of re-laying out the whole line.
1333
+ function extendRichLineWidth(width, previous, characters) {
1334
+ for (const character of characters) {
1335
+ if (previous) {
1336
+ width += previous.run.spacing;
1337
+ if (
1338
+ previous.font === character.font &&
1339
+ previous.run.size === character.run.size
1340
+ ) {
1341
+ width +=
1342
+ (character.font.getKerningValue(previous.glyph, character.glyph) *
1343
+ character.run.size) /
1344
+ character.font.unitsPerEm;
1345
+ }
1346
+ }
1347
+ width += character.advance;
1348
+ previous = character;
1349
+ }
1350
+ return width;
1351
+ }
1352
+
1242
1353
  function richTextLines(state, node, path, missing) {
1243
1354
  const lines = [];
1244
1355
  for (const block of node.textBlocks) {
@@ -1328,18 +1439,21 @@ function richTextLines(state, node, path, missing) {
1328
1439
  };
1329
1440
  for (const paragraph of paragraphs) {
1330
1441
  let line = [];
1442
+ let lineWidth = 0;
1331
1443
  let word = [];
1332
1444
  const appendWord = () => {
1333
- const candidate = line.concat(word);
1445
+ const candidateWidth = extendRichLineWidth(lineWidth, line.at(-1), word);
1334
1446
  if (
1335
1447
  line.length &&
1336
1448
  node.growType !== "auto-width" &&
1337
- richLineLayout(candidate).width > node.width
1449
+ Math.max(0, candidateWidth) > node.width
1338
1450
  ) {
1339
1451
  finish(line);
1340
1452
  line = word;
1453
+ lineWidth = extendRichLineWidth(0, undefined, word);
1341
1454
  } else {
1342
- line = candidate;
1455
+ for (const character of word) line.push(character);
1456
+ lineWidth = candidateWidth;
1343
1457
  }
1344
1458
  word = [];
1345
1459
  };
@@ -1386,21 +1500,19 @@ function renderRichText(state, node, matrix, opacity, path, clips) {
1386
1500
  pushRichSpan(spans, run, character.x, character.x + character.advance);
1387
1501
  continue;
1388
1502
  }
1389
- const contours = flattenPath(
1390
- character.glyph.getPath(x + character.x, baseline, run.size),
1503
+ renderGlyphOutline(
1504
+ state,
1505
+ node,
1506
+ matrix,
1507
+ inverseMatrix,
1508
+ character.glyph,
1509
+ x + character.x,
1510
+ baseline,
1511
+ run.size,
1512
+ run.color,
1513
+ opacity,
1514
+ clips,
1391
1515
  );
1392
- if (contours.length) {
1393
- renderGlyph(
1394
- state,
1395
- node,
1396
- matrix,
1397
- inverseMatrix,
1398
- { bounds: contourBounds(contours), contours },
1399
- run.color,
1400
- opacity,
1401
- clips,
1402
- );
1403
- }
1404
1516
  pushRichSpan(spans, run, character.x, character.x + character.advance);
1405
1517
  }
1406
1518
  for (const span of spans) {
@@ -1473,7 +1585,7 @@ function renderText(state, node, matrix, opacity, path, clips) {
1473
1585
  : wrapTextLines(
1474
1586
  source,
1475
1587
  node.width,
1476
- (text) =>
1588
+ (text, previous) =>
1477
1589
  lineGlyphs(
1478
1590
  font,
1479
1591
  state.fonts.symbols,
@@ -1482,8 +1594,10 @@ function renderText(state, node, matrix, opacity, path, clips) {
1482
1594
  0,
1483
1595
  fontSize,
1484
1596
  letterSpacing,
1485
- false,
1486
- ).width,
1597
+ undefined,
1598
+ undefined,
1599
+ previous,
1600
+ ),
1487
1601
  );
1488
1602
  if (node.growType === "auto-height") {
1489
1603
  const blockHeight = lines.length * lineHeight;
@@ -1520,7 +1634,6 @@ function renderText(state, node, matrix, opacity, path, clips) {
1520
1634
  0,
1521
1635
  fontSize,
1522
1636
  letterSpacing,
1523
- false,
1524
1637
  );
1525
1638
  const x =
1526
1639
  style.textAlign === "center"
@@ -1530,7 +1643,7 @@ function renderText(state, node, matrix, opacity, path, clips) {
1530
1643
  : 0;
1531
1644
  const baseline =
1532
1645
  verticalOffset + index * lineHeight + (lineHeight - fontSize) / 2 + ascender;
1533
- const placed = lineGlyphs(
1646
+ lineGlyphs(
1534
1647
  font,
1535
1648
  state.fonts.symbols,
1536
1649
  lines[index],
@@ -1538,12 +1651,22 @@ function renderText(state, node, matrix, opacity, path, clips) {
1538
1651
  baseline,
1539
1652
  fontSize,
1540
1653
  letterSpacing,
1541
- true,
1654
+ (glyph, glyphX) =>
1655
+ renderGlyphOutline(
1656
+ state,
1657
+ node,
1658
+ matrix,
1659
+ inverseMatrix,
1660
+ glyph,
1661
+ glyphX,
1662
+ baseline,
1663
+ fontSize,
1664
+ textColor,
1665
+ opacity,
1666
+ clips,
1667
+ ),
1542
1668
  missing,
1543
1669
  );
1544
- for (const glyph of placed.glyphs) {
1545
- renderGlyph(state, node, matrix, inverseMatrix, glyph, textColor, opacity, clips);
1546
- }
1547
1670
  renderTextDecoration(
1548
1671
  state,
1549
1672
  node,
@@ -1635,6 +1758,14 @@ function paintColor(
1635
1758
  );
1636
1759
  }
1637
1760
 
1761
+ function paintVaries(paint, state) {
1762
+ if (paint.type === "solid" || paint.type === "image") return false;
1763
+ const colorKey = paint.colorRef
1764
+ ? assetReferenceKey(paint.colorRef, state.productPackageId)
1765
+ : undefined;
1766
+ return !(colorKey && state.colors.has(colorKey));
1767
+ }
1768
+
1638
1769
  function svgAttributes(source) {
1639
1770
  return Object.fromEntries(
1640
1771
  [...source.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)].map(
@@ -1795,6 +1926,29 @@ function pushDecodeDiagnostic(diagnostics, path, name, detail) {
1795
1926
  });
1796
1927
  }
1797
1928
 
1929
+ // Every raster decoder checks declared dimensions against the same limits
1930
+ // before allocating pixel buffers.
1931
+ function rasterSizeAllowed(width, height) {
1932
+ return (
1933
+ Number.isInteger(width) &&
1934
+ Number.isInteger(height) &&
1935
+ width > 0 &&
1936
+ height > 0 &&
1937
+ width <= MAX_DIMENSION &&
1938
+ height <= MAX_DIMENSION &&
1939
+ width * height <= MAX_PIXELS
1940
+ );
1941
+ }
1942
+
1943
+ function pushSizeDiagnostic(diagnostics, path, name, width, height) {
1944
+ pushDecodeDiagnostic(
1945
+ diagnostics,
1946
+ path,
1947
+ name,
1948
+ `image size ${width}x${height} exceeds the renderer limits (${MAX_DIMENSION}px per side, ${MAX_PIXELS} pixels)`,
1949
+ );
1950
+ }
1951
+
1798
1952
  function decodePng(bytes, diagnostics, path, name) {
1799
1953
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
1800
1954
  const signature = [137, 80, 78, 71, 13, 10, 26, 10];
@@ -1841,17 +1995,14 @@ function decodePng(bytes, diagnostics, path, name) {
1841
1995
  }
1842
1996
  offset += 12 + length;
1843
1997
  }
1844
- if (
1845
- !header ||
1846
- idat.length === 0 ||
1847
- header.width <= 0 ||
1848
- header.height <= 0 ||
1849
- header.width > MAX_DIMENSION ||
1850
- header.height > MAX_DIMENSION
1851
- ) {
1998
+ if (!header || idat.length === 0 || header.width <= 0 || header.height <= 0) {
1852
1999
  pushDecodeDiagnostic(diagnostics, path, name, "missing or invalid PNG header");
1853
2000
  return undefined;
1854
2001
  }
2002
+ if (!rasterSizeAllowed(header.width, header.height)) {
2003
+ pushSizeDiagnostic(diagnostics, path, name, header.width, header.height);
2004
+ return undefined;
2005
+ }
1855
2006
  if (
1856
2007
  header.depth !== 8 ||
1857
2008
  ![0, 2, 3, 4, 6].includes(header.colorType) ||
@@ -1868,14 +2019,25 @@ function decodePng(bytes, diagnostics, path, name) {
1868
2019
  return undefined;
1869
2020
  }
1870
2021
  const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[header.colorType];
2022
+ const stride = header.width * channels;
1871
2023
  let raw;
1872
2024
  try {
1873
- raw = inflateSync(Buffer.concat(idat));
1874
- } catch {
1875
- pushDecodeDiagnostic(diagnostics, path, name, "corrupt PNG pixel data");
2025
+ // Bound the output to the declared image size so a small compressed
2026
+ // stream cannot expand without limit.
2027
+ raw = inflateSync(Buffer.concat(idat), {
2028
+ maxOutputLength: (stride + 1) * header.height,
2029
+ });
2030
+ } catch (error) {
2031
+ pushDecodeDiagnostic(
2032
+ diagnostics,
2033
+ path,
2034
+ name,
2035
+ error?.code === "ERR_BUFFER_TOO_LARGE"
2036
+ ? "PNG pixel data is larger than its declared size"
2037
+ : "corrupt PNG pixel data",
2038
+ );
1876
2039
  return undefined;
1877
2040
  }
1878
- const stride = header.width * channels;
1879
2041
  if (raw.length < (stride + 1) * header.height) {
1880
2042
  pushDecodeDiagnostic(diagnostics, path, name, "truncated PNG pixel data");
1881
2043
  return undefined;
@@ -1968,6 +2130,10 @@ function decodeGif(bytes, diagnostics, path, name) {
1968
2130
  const data = new DataView(view.buffer, view.byteOffset, view.byteLength);
1969
2131
  const width = data.getUint16(6, true);
1970
2132
  const height = data.getUint16(8, true);
2133
+ if (!rasterSizeAllowed(width, height)) {
2134
+ pushSizeDiagnostic(diagnostics, path, name, width, height);
2135
+ return undefined;
2136
+ }
1971
2137
  const packed = view[10];
1972
2138
  let offset = 13;
1973
2139
  let globalTable;
@@ -2005,10 +2171,18 @@ function decodeGif(bytes, diagnostics, path, name) {
2005
2171
  pushDecodeDiagnostic(diagnostics, path, name, "corrupt GIF block structure");
2006
2172
  return undefined;
2007
2173
  }
2174
+ if (offset + 9 > view.length) {
2175
+ pushDecodeDiagnostic(diagnostics, path, name, "truncated GIF image descriptor");
2176
+ return undefined;
2177
+ }
2008
2178
  const frameX = data.getUint16(offset, true);
2009
2179
  const frameY = data.getUint16(offset + 2, true);
2010
2180
  const frameWidth = data.getUint16(offset + 4, true);
2011
2181
  const frameHeight = data.getUint16(offset + 6, true);
2182
+ if (!rasterSizeAllowed(frameWidth, frameHeight)) {
2183
+ pushSizeDiagnostic(diagnostics, path, name, frameWidth, frameHeight);
2184
+ return undefined;
2185
+ }
2012
2186
  const framePacked = view[offset + 8];
2013
2187
  offset += 9;
2014
2188
  let table = globalTable;
@@ -2227,7 +2401,12 @@ function decodeJpeg(bytes, diagnostics, path, name) {
2227
2401
  });
2228
2402
  cursor += 3;
2229
2403
  }
2230
- if (frame.width <= 0 || frame.height <= 0) frame = undefined;
2404
+ if (frame.width <= 0 || frame.height <= 0) {
2405
+ frame = undefined;
2406
+ } else if (!rasterSizeAllowed(frame.width, frame.height)) {
2407
+ pushSizeDiagnostic(diagnostics, path, name, frame.width, frame.height);
2408
+ return undefined;
2409
+ }
2231
2410
  } else if (marker === 0xc2 || marker === 0xc3 || (marker >= 0xc5 && marker <= 0xcf)) {
2232
2411
  pushDecodeDiagnostic(
2233
2412
  diagnostics,
@@ -2646,11 +2825,18 @@ function decodeMediaBytes(media, bytes, diagnostics, path) {
2646
2825
  kind: "raster",
2647
2826
  value: decodeJpeg(bytes, diagnostics, path, media.name),
2648
2827
  };
2649
- case "image/webp":
2828
+ case "image/webp": {
2829
+ // libwebp allocates the full canvas; check the header first.
2830
+ const size = webpDimensions(bytes);
2831
+ if (size && !rasterSizeAllowed(size.width, size.height)) {
2832
+ pushSizeDiagnostic(diagnostics, path, media.name, size.width, size.height);
2833
+ return { kind: "raster", value: undefined };
2834
+ }
2650
2835
  return {
2651
2836
  kind: "raster",
2652
2837
  value: decodeWebpSync(bytes, diagnostics, path, media.name),
2653
2838
  };
2839
+ }
2654
2840
  default:
2655
2841
  diagnostics.push({
2656
2842
  code: "media_decode_unsupported",
@@ -2930,16 +3116,22 @@ function renderNodeShadow(state, node, matrix, opacity, path, clips, geometry) {
2930
3116
  });
2931
3117
  continue;
2932
3118
  }
2933
- const offsetX = Number(shadow.offsetX ?? 0);
2934
- const offsetY = Number(shadow.offsetY ?? 0);
3119
+ // Token shadows are DTCG objects; shadows edited in Penpot keep its
3120
+ // kebab-case offsets and a {color, opacity} color.
3121
+ const offsetX = Number(shadow.offsetX ?? shadow["offset-x"] ?? 0);
3122
+ const offsetY = Number(shadow.offsetY ?? shadow["offset-y"] ?? 0);
2935
3123
  const blur = Number(shadow.blur ?? 0);
3124
+ const paint = isRecordLike(shadow.color) ? shadow.color : undefined;
2936
3125
  const color = parseColor(
2937
- typeof shadow.color === "string" ? shadow.color : "#000000",
3126
+ typeof shadow.color === "string"
3127
+ ? shadow.color
3128
+ : typeof paint?.color === "string" ? paint.color : "#000000",
2938
3129
  state.diagnostics,
2939
3130
  `${path}.shadow`,
2940
3131
  );
2941
- const shadowOpacity = Number.isFinite(Number(shadow.opacity))
2942
- ? clamp(Number(shadow.opacity), 0, 1)
3132
+ const rawOpacity = shadow.opacity ?? paint?.opacity;
3133
+ const shadowOpacity = Number.isFinite(Number(rawOpacity))
3134
+ ? clamp(Number(rawOpacity), 0, 1)
2943
3135
  : 1;
2944
3136
  const offsetDeviceX =
2945
3137
  (matrix[0] * offsetX + matrix[2] * offsetY) * state.scale;
@@ -3167,6 +3359,30 @@ function childClips(state, nodes, node, matrix, clips) {
3167
3359
  return clips;
3168
3360
  }
3169
3361
 
3362
+ function inkBounds(state, margin) {
3363
+ let left = state.width;
3364
+ let right = 0;
3365
+ let top = state.height;
3366
+ let bottom = 0;
3367
+ for (let pixelY = 0; pixelY < state.height; pixelY += 1) {
3368
+ const row = pixelY * state.width;
3369
+ for (let pixelX = 0; pixelX < state.width; pixelX += 1) {
3370
+ if (state.pixels[(row + pixelX) * 4 + 3] === 0) continue;
3371
+ if (pixelX < left) left = pixelX;
3372
+ if (pixelX >= right) right = pixelX + 1;
3373
+ if (pixelY < top) top = pixelY;
3374
+ bottom = pixelY + 1;
3375
+ }
3376
+ }
3377
+ if (right <= left) return undefined;
3378
+ return {
3379
+ bottom: Math.min(state.height, bottom + margin),
3380
+ left: Math.max(0, left - margin),
3381
+ right: Math.min(state.width, right + margin),
3382
+ top: Math.max(0, top - margin),
3383
+ };
3384
+ }
3385
+
3170
3386
  function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, clips) {
3171
3387
  if (stack.has(nodeId)) throw new SmallPenError("node_cycle", `Render cycle includes ${nodeId}`);
3172
3388
  const node = nodes[nodeId];
@@ -3204,28 +3420,38 @@ function renderTree(state, nodes, nodeId, parentMatrix, parentOpacity, stack, cl
3204
3420
  stack.delete(nodeId);
3205
3421
  for (const entry of scratch.diagnostics) state.diagnostics.push(entry);
3206
3422
  if (scratch.missingGlyphs) mergeMissingGlyphs(state, scratch.missingGlyphs);
3423
+ // Blur and composite only around the subtree's ink: three box passes
3424
+ // spread it at most three radii, and transparent pixels blend to no-ops.
3425
+ const region = inkBounds(
3426
+ scratch,
3427
+ 3 * Math.round(layerBlurRadius * state.scale) + 1,
3428
+ );
3429
+ if (!region) return;
3207
3430
  blurRgbaRegion(
3208
3431
  scratch.pixels,
3209
3432
  state.width,
3210
3433
  state.height,
3211
- 0,
3212
- 0,
3213
- state.width,
3214
- state.height,
3434
+ region.left,
3435
+ region.top,
3436
+ region.right,
3437
+ region.bottom,
3215
3438
  layerBlurRadius * state.scale,
3216
3439
  );
3217
- for (let index = 0; index < state.pixels.length; index += 4) {
3218
- blend(
3219
- state.pixels,
3220
- index,
3221
- [
3222
- scratch.pixels[index],
3223
- scratch.pixels[index + 1],
3224
- scratch.pixels[index + 2],
3225
- scratch.pixels[index + 3],
3226
- ],
3227
- 1,
3228
- );
3440
+ for (let pixelY = region.top; pixelY < region.bottom; pixelY += 1) {
3441
+ for (let pixelX = region.left; pixelX < region.right; pixelX += 1) {
3442
+ const index = (pixelY * state.width + pixelX) * 4;
3443
+ blend(
3444
+ state.pixels,
3445
+ index,
3446
+ [
3447
+ scratch.pixels[index],
3448
+ scratch.pixels[index + 1],
3449
+ scratch.pixels[index + 2],
3450
+ scratch.pixels[index + 3],
3451
+ ],
3452
+ 1,
3453
+ );
3454
+ }
3229
3455
  }
3230
3456
  return;
3231
3457
  }
@@ -3294,6 +3520,20 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3294
3520
  path,
3295
3521
  });
3296
3522
  }
3523
+ // Only gradients vary per pixel; resolve any other paint once.
3524
+ const uniformColor =
3525
+ raster || media?.image || paintVaries(fill, state)
3526
+ ? undefined
3527
+ : paintColor(
3528
+ fill,
3529
+ 0,
3530
+ 0,
3531
+ node,
3532
+ state.colors,
3533
+ state.diagnostics,
3534
+ path,
3535
+ state.productPackageId,
3536
+ );
3297
3537
  for (let pixelY = bounds.top; pixelY < bounds.bottom; pixelY += 1) {
3298
3538
  for (let pixelX = bounds.left; pixelX < bounds.right; pixelX += 1) {
3299
3539
  const canvasX = (pixelX + 0.5) / state.scale;
@@ -3313,16 +3553,18 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3313
3553
  ? rasterSample(raster, local.x, local.y, node)
3314
3554
  : media?.image
3315
3555
  ? svgSample(media.image, local.x, local.y, node)
3316
- : paintColor(
3317
- fill,
3318
- local.x,
3319
- local.y,
3320
- node,
3321
- state.colors,
3322
- state.diagnostics,
3323
- path,
3324
- state.productPackageId,
3325
- );
3556
+ : uniformColor
3557
+ ? [...uniformColor]
3558
+ : paintColor(
3559
+ fill,
3560
+ local.x,
3561
+ local.y,
3562
+ node,
3563
+ state.colors,
3564
+ state.diagnostics,
3565
+ path,
3566
+ state.productPackageId,
3567
+ );
3326
3568
  if (fill.type === "image" && !media?.image && !raster) {
3327
3569
  const band = (Math.floor(local.x / 8) + Math.floor(local.y / 8)) % 2;
3328
3570
  color[0] -= band * 18;
@@ -3360,6 +3602,12 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3360
3602
  width,
3361
3603
  )
3362
3604
  : undefined;
3605
+ const markerStartOutline = markerStart?.segments.length
3606
+ ? makeGeometry(markerStart.segments)
3607
+ : undefined;
3608
+ const markerEndOutline = markerEnd?.segments.length
3609
+ ? makeGeometry(markerEnd.segments)
3610
+ : undefined;
3363
3611
  for (let pixelY = bounds.top; pixelY < bounds.bottom; pixelY += 1) {
3364
3612
  for (let pixelX = bounds.left; pixelX < bounds.right; pixelX += 1) {
3365
3613
  const canvasX = (pixelX + 0.5) / state.scale;
@@ -3394,9 +3642,9 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3394
3642
  if (!covered && markerStart) {
3395
3643
  covered =
3396
3644
  windingNumber(markerStart.contours, local.x, local.y) !== 0 ||
3397
- (markerStart.segments.length > 0 &&
3645
+ (markerStartOutline !== undefined &&
3398
3646
  pathStrokeContains(
3399
- makeGeometry(markerStart.segments),
3647
+ markerStartOutline,
3400
3648
  local.x,
3401
3649
  local.y,
3402
3650
  width,
@@ -3405,9 +3653,9 @@ function renderNodeShape(state, node, matrix, opacity, path, clips) {
3405
3653
  if (!covered && markerEnd) {
3406
3654
  covered =
3407
3655
  windingNumber(markerEnd.contours, local.x, local.y) !== 0 ||
3408
- (markerEnd.segments.length > 0 &&
3656
+ (markerEndOutline !== undefined &&
3409
3657
  pathStrokeContains(
3410
- makeGeometry(markerEnd.segments),
3658
+ markerEndOutline,
3411
3659
  local.x,
3412
3660
  local.y,
3413
3661
  width,