@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/assets/fonts/SourceSansPro-OFL.txt +46 -0
- package/assets/fonts/WorkSans-OFL.txt +46 -0
- package/package.json +2 -2
- package/src/draft-import.mjs +1 -11
- package/src/font-convert.mjs +23 -1
- package/src/fs-utils.mjs +95 -0
- package/src/index.mjs +6 -1
- package/src/initialize.mjs +1 -11
- package/src/kiwi-runtime.mjs +38 -36
- package/src/library-network.mjs +189 -0
- package/src/local-package-backend.mjs +215 -24
- package/src/local-package.mjs +532 -176
- package/src/local-workspace-session.mjs +223 -95
- package/src/remote-library.mjs +167 -28
- package/src/render.mjs +342 -94
- package/src/webp-decode.mjs +32 -0
- package/src/workspace.mjs +85 -15
package/src/render.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
SmallPenError,
|
|
12
12
|
} from "@smallpen/core";
|
|
13
13
|
|
|
14
|
-
import { initWebpDecoder,
|
|
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
|
-
|
|
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
|
-
|
|
1019
|
+
onGlyph,
|
|
999
1020
|
missing,
|
|
1021
|
+
start,
|
|
1000
1022
|
) {
|
|
1001
|
-
const
|
|
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
|
-
|
|
1031
|
-
let cursor = x;
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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(
|
|
1060
|
+
(glyphFont.getKerningValue(previous.glyph, glyph) * size) /
|
|
1037
1061
|
glyphFont.unitsPerEm;
|
|
1038
1062
|
}
|
|
1039
|
-
if (
|
|
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 {
|
|
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 =
|
|
1059
|
-
if (line &&
|
|
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
|
|
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
|
|
1445
|
+
const candidateWidth = extendRichLineWidth(lineWidth, line.at(-1), word);
|
|
1334
1446
|
if (
|
|
1335
1447
|
line.length &&
|
|
1336
1448
|
node.growType !== "auto-width" &&
|
|
1337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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)
|
|
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
|
-
|
|
2934
|
-
|
|
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"
|
|
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
|
|
2942
|
-
|
|
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
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3434
|
+
region.left,
|
|
3435
|
+
region.top,
|
|
3436
|
+
region.right,
|
|
3437
|
+
region.bottom,
|
|
3215
3438
|
layerBlurRadius * state.scale,
|
|
3216
3439
|
);
|
|
3217
|
-
for (let
|
|
3218
|
-
|
|
3219
|
-
state.
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
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
|
-
:
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
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
|
-
(
|
|
3645
|
+
(markerStartOutline !== undefined &&
|
|
3398
3646
|
pathStrokeContains(
|
|
3399
|
-
|
|
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
|
-
(
|
|
3656
|
+
(markerEndOutline !== undefined &&
|
|
3409
3657
|
pathStrokeContains(
|
|
3410
|
-
|
|
3658
|
+
markerEndOutline,
|
|
3411
3659
|
local.x,
|
|
3412
3660
|
local.y,
|
|
3413
3661
|
width,
|