@openpresentation/opf-render 0.1.0 → 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/README.md +21 -1
- package/dist/raster-images.js +85 -0
- package/dist/raster.js +2 -1
- package/dist/svg.js +9 -1
- package/package.json +12 -7
package/README.md
CHANGED
|
@@ -104,8 +104,28 @@ This repo does not require an npm automation token when Trusted Publishing is co
|
|
|
104
104
|
|
|
105
105
|
The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
|
|
106
106
|
|
|
107
|
-
Version 0.
|
|
107
|
+
Version 0.2.x requires the published `@openpresentation/opf@^0.4.1` for composition, pagination and measured text. A clean `npm ci && npm test` uses registry packages and runs the full raster corpus without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` checks editing, rendering, editable PowerPoint geometry and import together.
|
|
108
108
|
|
|
109
109
|
For actual font measurement, load `loadBundledFontRegistry()` from `@openpresentation/opf-render/fonts-node`, then pass its `textMeasurement` to rendering and its `embeddedFonts` to SVG export. Pass its `fontFiles` to PNG/PDF conversion. The browser-safe `@openpresentation/opf-render/fonts` entry accepts local font bytes. Missing fonts/glyphs fail explicitly; aliases and fallback are opt-in and recorded in `registry.substitutions`. Bundled font license notices travel with embedded SVG fonts.
|
|
110
110
|
|
|
111
111
|
Raster updates produce an HTML gallery and `artifacts/golden/candidate.json`; they never overwrite the approved baseline automatically. See [baseline review and known limitations](test/golden/README.md).
|
|
112
|
+
|
|
113
|
+
Version 0.1.1 adds rich-text line offsets and measured boxes to opt-in SVG tracing, including blank lines, for editor caret placement. Normal SVG/PNG output is unchanged.
|
|
114
|
+
|
|
115
|
+
## Embedded WebP in PNG/PDF output (unreleased)
|
|
116
|
+
|
|
117
|
+
PNG and PDF conversion now decode embedded WebP image data URIs to static PNG before rasterization. This preserves fit/crop, transparency, EXIF orientation and the first animation frame. The source SVG and OPF document remain unchanged; browser SVG output keeps the original embedded image. Both href and legacy xlink:href are supported, including base64 and percent-encoded data. This step does not resolve local filenames, download remote images or change the existing host imageResolver contract.
|
|
118
|
+
|
|
119
|
+
The Node-only raster path lazily loads pinned Sharp 0.35.4, requiring Node 20.9 or later and normal installation of its optional platform binaries. Browser exports do not include the decoder. Malformed embedded WebP produces image-conversion-failed with its data-opf-path when present, otherwise svg.images.N. Decoding has a 40-megapixel limit. PNG/PDF are static outputs; animation beyond the first frame and original image metadata are not retained.
|
|
120
|
+
|
|
121
|
+
Regression checks compare six fixtures against independent Pillow pixels, verify fit/crop, inspect actual PDF image streams and transparency masks, and exercise input encodings and diagnostics. All 805 existing raster baselines remain unchanged. Test fixtures are project-authored and MIT licensed; the decoder and its bundled codecs retain their upstream licenses.
|
|
122
|
+
|
|
123
|
+
## JPEG EXIF orientation in raster output (unreleased)
|
|
124
|
+
|
|
125
|
+
PNG/PDF raster preparation now honors all eight JPEG EXIF orientations. JPEGs with orientation 2–8 are decoded to oriented PNG pixels for rasterization; JPEGs with orientation 1 or no orientation keep their original SVG attribute spelling and compressed bytes. This does not modify the source document or the browser SVG output. The Node path reads JPEG metadata through the same lazy Sharp dependency used for WebP.
|
|
126
|
+
|
|
127
|
+
Twenty-four cases verify fit/crop, PNG pixels and actual PDF image streams against independent Pillow references (maximum two channel values of decoder difference). Sixteen browser OPF-SVG checks verify orientation and fit/crop with an explicitly incorrect-orientation control. Browser JPEG/PNG scaling differs around high-contrast edges: measured average channel error is below 0.62, but individual differences reach 49. These checks therefore establish geometry/orientation agreement, not pixel-identical output across engines.
|
|
128
|
+
|
|
129
|
+
`npm test` also builds the browser comparison and rejects native raster modules in browser output. Serve this repository and open `/artifacts/jpeg/browser/index.html` to run it. The harness permits mean channel error up to 1 and at most 2% of channels differing by more than 10; a deliberately unrotated image must fail that criterion.
|
|
130
|
+
|
|
131
|
+
Version 0.2.0 requires Node 20.9 or later for native image decoding. Browser bundles continue using browser-safe entrypoints.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {OPFRenderError} from './svg.js';
|
|
2
|
+
|
|
3
|
+
// Resvg does not decode WebP or apply JPEG EXIF orientation. Replace only
|
|
4
|
+
// affected embedded image hrefs in the private
|
|
5
|
+
// rasterization copy; preserve SVG text, attributes, comments and source bytes.
|
|
6
|
+
export async function prepareRasterImages(svg) {
|
|
7
|
+
if (!/webp|jpeg|&#/i.test(svg)) return svg;
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
let output = '', end = 0, imageIndex = 0;
|
|
10
|
+
const tokens = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<(?:"[^"]*"|'[^']*'|[^'">])*>/g;
|
|
11
|
+
for (const match of svg.matchAll(tokens)) {
|
|
12
|
+
let tag = match[0];
|
|
13
|
+
if (!/^<(?:[A-Za-z_][\w.-]*:)?image(?=[\s/>])/.test(tag)) continue;
|
|
14
|
+
const index = imageIndex++;
|
|
15
|
+
const href = /\shref\s*=\s*(["'])([\s\S]*?)\1/.exec(tag)
|
|
16
|
+
?? /\sxlink:href\s*=\s*(["'])([\s\S]*?)\1/.exec(tag);
|
|
17
|
+
if (!href) continue;
|
|
18
|
+
const uri = xmlText(href[2]);
|
|
19
|
+
if (!/^data:image\/(?:webp|jpeg)(?:;|,)/i.test(uri)) continue;
|
|
20
|
+
const trace = /\sdata-opf-path\s*=\s*(["'])([\s\S]*?)\1/.exec(tag);
|
|
21
|
+
const path = trace ? xmlText(trace[2]) : `svg.images.${index}`;
|
|
22
|
+
try {
|
|
23
|
+
if (!cache.has(uri)) cache.set(uri, prepareRasterImage(uri));
|
|
24
|
+
const png = await cache.get(uri);
|
|
25
|
+
if (png === null) continue;
|
|
26
|
+
const replacement = href[0].slice(0, href[0].indexOf(href[1]) + 1) + png + href[1];
|
|
27
|
+
tag = tag.slice(0, href.index) + replacement + tag.slice(href.index + href[0].length);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new OPFRenderError('image-conversion-failed', 'Embedded raster image could not be prepared for PNG/PDF output.', {path, cause:error instanceof Error ? error.message : String(error)});
|
|
30
|
+
}
|
|
31
|
+
output += svg.slice(end, match.index) + tag;
|
|
32
|
+
end = match.index + match[0].length;
|
|
33
|
+
}
|
|
34
|
+
return output + svg.slice(end);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function prepareRasterImage(uri) {
|
|
38
|
+
const comma = uri.indexOf(',');
|
|
39
|
+
const header = uri.slice(0, comma), data = uri.slice(comma + 1);
|
|
40
|
+
let bytes;
|
|
41
|
+
if (/;base64$/i.test(header)) {
|
|
42
|
+
const encoded = decodeURIComponent(data).replace(/\s/g, '');
|
|
43
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.length % 4 === 1) throw new Error('Invalid base64 image data.');
|
|
44
|
+
bytes = Buffer.from(encoded, 'base64');
|
|
45
|
+
} else {
|
|
46
|
+
const result = [];
|
|
47
|
+
for (let index=0; index<data.length;) {
|
|
48
|
+
if (data[index]==='%') {
|
|
49
|
+
if (!/^[0-9a-f]{2}$/i.test(data.slice(index+1,index+3))) throw new Error('Invalid percent-encoded image data.');
|
|
50
|
+
result.push(parseInt(data.slice(index+1,index+3),16)); index+=3;
|
|
51
|
+
} else {
|
|
52
|
+
const point=String.fromCodePoint(data.codePointAt(index));
|
|
53
|
+
result.push(...new TextEncoder().encode(point)); index+=point.length;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
bytes=Uint8Array.from(result);
|
|
57
|
+
}
|
|
58
|
+
const webp=/^data:image\/webp(?:;|,)/i.test(uri);
|
|
59
|
+
if (webp) {
|
|
60
|
+
if (bytes.length < 12 || Buffer.from(bytes.subarray(0,4)).toString('ascii') !== 'RIFF' || Buffer.from(bytes.subarray(8,12)).toString('ascii') !== 'WEBP') throw new Error('Embedded data is not a WebP image.');
|
|
61
|
+
} else if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) {
|
|
62
|
+
throw new Error('Embedded data is not a JPEG image.');
|
|
63
|
+
}
|
|
64
|
+
const {default:sharp}=await import('sharp');
|
|
65
|
+
const decoder=sharp(bytes,{limitInputPixels:40_000_000,animated:false,failOn:'warning'});
|
|
66
|
+
if (!webp) {
|
|
67
|
+
const {orientation}=await decoder.metadata();
|
|
68
|
+
// Keep unoriented JPEGs on the existing resvg path, including their
|
|
69
|
+
// original compressed bytes and established color/raster behavior.
|
|
70
|
+
if (!(orientation >= 2 && orientation <= 8)) return null;
|
|
71
|
+
}
|
|
72
|
+
const png=await decoder.autoOrient().toColourspace('srgb').ensureAlpha()
|
|
73
|
+
.png({compressionLevel:9,adaptiveFiltering:false}).toBuffer();
|
|
74
|
+
return 'data:image/png;base64,'+png.toString('base64');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function xmlText(value) {
|
|
78
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|amp|quot|apos|lt|gt);/gi, (entity, code) => {
|
|
79
|
+
if (code[0]==='#') {
|
|
80
|
+
const point=code[1].toLowerCase()==='x'?parseInt(code.slice(2),16):parseInt(code.slice(1),10);
|
|
81
|
+
return point>0&&point<=0x10ffff?String.fromCodePoint(point):entity;
|
|
82
|
+
}
|
|
83
|
+
return {amp:'&',quot:'"',apos:"'",lt:'<',gt:'>'}[code] ?? entity;
|
|
84
|
+
});
|
|
85
|
+
}
|
package/dist/raster.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {prepareRasterImages} from './raster-images.js';
|
|
1
2
|
import {OPFRenderError,packageName} from './svg.js';
|
|
2
3
|
const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
|
|
3
4
|
const DEFAULT_RASTER_SCALE = 1;
|
|
@@ -39,7 +40,7 @@ export async function svgToPdf(svgs, options = {}) {
|
|
|
39
40
|
|
|
40
41
|
async function rasterizeSvg(svgInput, options) {
|
|
41
42
|
const { Resvg } = await loadResvg();
|
|
42
|
-
const svg = normalizeSvgInput(svgInput);
|
|
43
|
+
const svg = await prepareRasterImages(normalizeSvgInput(svgInput));
|
|
43
44
|
const scale = positiveNumber(options.scale, "scale", DEFAULT_RASTER_SCALE);
|
|
44
45
|
const fontFiles = [
|
|
45
46
|
...(options.useBundledFonts === false ? [] : await bundledFontFiles()),
|
package/dist/svg.js
CHANGED
|
@@ -1182,7 +1182,15 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1182
1182
|
if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1183
1183
|
return rendered;
|
|
1184
1184
|
}));
|
|
1185
|
-
|
|
1185
|
+
let cursor=0;
|
|
1186
|
+
const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
|
|
1187
|
+
const lineTrace=config.options.trace?fit.richLines.map((line,index)=>{
|
|
1188
|
+
if(index){const newline=/^(\r\n|\r|\n)/.exec(whole.slice(cursor));if(newline)cursor+=newline[0].length;}
|
|
1189
|
+
const start=cursor;cursor+=(fit.lines[index]??'').length;
|
|
1190
|
+
const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1191
|
+
return {start,end:cursor,x:box.x+offset,y:box.y+line.y,height:line.height};
|
|
1192
|
+
}):undefined;
|
|
1193
|
+
return tag('g',{...traceAttrs(config.options,config.path),...(config.options.trace?{'data-opf-box-width':box.width,'data-opf-rich-text':config.rich===false?undefined:'true','data-opf-rich-lines':JSON.stringify(lineTrace)}:{}),...(fit.overflow?{'data-opf-overflow':'true'}:{})},content.join('\n'));
|
|
1186
1194
|
}
|
|
1187
1195
|
|
|
1188
1196
|
function renderTextBox(text, box, bound, config) {
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"private": false,
|
|
8
8
|
"engines": {
|
|
9
|
-
"node": ">=20"
|
|
9
|
+
"node": ">=20.9.0"
|
|
10
10
|
},
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
@@ -55,12 +55,13 @@
|
|
|
55
55
|
"sideEffects": false,
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "node scripts/build.mjs",
|
|
58
|
-
"typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/golden.mjs && node --check src/fonts.js && node --check src/fonts-node.js && node --check src/font-compatibility.js && node --check test/office-fonts.mjs && node --check src/fonts-browser.js && node --check test/fonts-browser.mjs && node --check src/svg.js && node --check src/raster.js",
|
|
58
|
+
"typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/golden.mjs && node --check src/fonts.js && node --check src/fonts-node.js && node --check src/font-compatibility.js && node --check test/office-fonts.mjs && node --check src/fonts-browser.js && node --check test/fonts-browser.mjs && node --check src/svg.js && node --check src/raster.js && node --check src/raster-images.js && node --check test/webp.mjs && node --check test/jpeg-orientation.mjs && node --check scripts/build-browser-check.mjs && node --check test/jpeg-browser.js",
|
|
59
59
|
"validate": "node scripts/validate-package.mjs",
|
|
60
|
-
"test": "npm run build && node test/smoke.mjs && node test/fonts.mjs && node test/office-fonts.mjs && node test/timeline.mjs && node test/golden-gate.mjs && node test/golden.mjs && node test/fonts-browser.mjs && node test/design-preview.mjs",
|
|
60
|
+
"test": "npm run build && node test/smoke.mjs && node test/webp.mjs && node test/jpeg-orientation.mjs && node test/fonts.mjs && node test/office-fonts.mjs && node test/timeline.mjs && node test/rich-trace.mjs && node test/golden-gate.mjs && node test/golden.mjs && node test/fonts-browser.mjs && node test/design-preview.mjs && npm run build:browser-check",
|
|
61
61
|
"test:golden": "npm run build && node test/golden.mjs",
|
|
62
62
|
"golden:update": "npm run build && node test/golden.mjs --update",
|
|
63
|
-
"prepack": "npm run build"
|
|
63
|
+
"prepack": "npm run build",
|
|
64
|
+
"build:browser-check": "node scripts/build-browser-check.mjs"
|
|
64
65
|
},
|
|
65
66
|
"dependencies": {
|
|
66
67
|
"@expo-google-fonts/arimo": "0.4.3",
|
|
@@ -71,9 +72,13 @@
|
|
|
71
72
|
"@expo-google-fonts/roboto": "^0.4.3",
|
|
72
73
|
"@expo-google-fonts/roboto-mono": "^0.4.2",
|
|
73
74
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
74
|
-
"@openpresentation/opf": "^0.4.
|
|
75
|
+
"@openpresentation/opf": "^0.4.1",
|
|
75
76
|
"@resvg/resvg-js": "^2.6.2",
|
|
76
77
|
"fontkit": "^2.0.4",
|
|
77
|
-
"pdf-lib": "^1.17.1"
|
|
78
|
+
"pdf-lib": "^1.17.1",
|
|
79
|
+
"sharp": "0.35.4"
|
|
80
|
+
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"esbuild": "0.28.2"
|
|
78
83
|
}
|
|
79
84
|
}
|