@openpresentation/opf-render 0.7.0 → 0.8.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 +58 -6
- package/dist/font-manifest.js +326 -0
- package/dist/fonts-node.d.ts +23 -1
- package/dist/fonts-node.js +59 -29
- package/dist/fonts.d.ts +2 -2
- package/dist/fonts.js +46 -12
- package/dist/index.d.ts +16 -1
- package/dist/raster.js +6 -18
- package/dist/svg.d.ts +18 -1
- package/dist/svg.js +140 -111
- package/package.json +19 -8
package/dist/svg.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { layoutTable, fitList, fitRichText, composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, fitText } from "@openpresentation/opf/composition";
|
|
1
|
+
import { layoutTable, fitList, fitRichText, composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, fitText, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
|
|
2
2
|
import {
|
|
3
3
|
catalogs as bundledCatalogs,
|
|
4
4
|
validatePresentation
|
|
@@ -485,7 +485,7 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
485
485
|
|
|
486
486
|
const design = resolveDesign(presentation, slide, context);
|
|
487
487
|
for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},context.options.textMeasurement).fontFamily;
|
|
488
|
-
const geometry = composeSlide(slide, { ...design.dimensions, layout, slideIndex: index, fonts: design.fonts, textMeasurement: context.options.textMeasurement });
|
|
488
|
+
const geometry = composeSlide(slide, { ...design.dimensions, layout, slideIndex: index, fonts: design.fonts, contentAlignment:design.contentAlignment, titleAlignment:design.titleAlignment, textRasterPadding:context.options.textRasterPadding, contentBox:design.contentBox, textMeasurement: context.options.textMeasurement });
|
|
489
489
|
return {
|
|
490
490
|
geometry,
|
|
491
491
|
assets: presentation.assets ?? {},
|
|
@@ -622,7 +622,8 @@ function renderBackground(bound, width, height, options) {
|
|
|
622
622
|
function renderSlideContent(bound, width, height, options) {
|
|
623
623
|
for (const diagnostic of bound.geometry.diagnostics) reportDiagnostic(diagnostic, options);
|
|
624
624
|
return bound.geometry.items.map(item => {
|
|
625
|
-
const
|
|
625
|
+
const frame=item.frameBox;
|
|
626
|
+
const surface=frame ? tag('rect',{x:frame.x,y:frame.y,width:frame.width,height:frame.height,rx:8*Math.min(width,height)/720,fill:bound.design.colors.surface,stroke:bound.design.colors.border}) : '';
|
|
626
627
|
return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options);
|
|
627
628
|
});
|
|
628
629
|
}
|
|
@@ -669,6 +670,9 @@ function renderTextPayload(item, box, bound, options) {
|
|
|
669
670
|
fontFamily: item.field === "title" ? bound.design.fonts.heading : bound.design.fonts.body,
|
|
670
671
|
fontWeight: item.field === "title" ? 700 : 400,
|
|
671
672
|
fill: bound.design.colors.text,
|
|
673
|
+
fit: item.text,
|
|
674
|
+
textStyle: item.textStyle,
|
|
675
|
+
diagnosticsHandled: Boolean(item.text),
|
|
672
676
|
options
|
|
673
677
|
});
|
|
674
678
|
}
|
|
@@ -689,11 +693,14 @@ function renderList(item, box, bound, options) {
|
|
|
689
693
|
function renderImage(item, box, bound, options) {
|
|
690
694
|
let asset = normalizeAsset(item.value);
|
|
691
695
|
const seen = new Set();
|
|
696
|
+
let missingReference;
|
|
692
697
|
while (asset.src?.startsWith("asset:")) {
|
|
693
698
|
const id = asset.src.slice(6);
|
|
694
699
|
if (seen.has(id)) throw new OPFRenderError("invalid-asset-reference", "Circular asset reference.", { path: item.path });
|
|
695
700
|
seen.add(id);
|
|
696
|
-
|
|
701
|
+
if (!Object.hasOwn(bound.assets,id)) { missingReference=id; break; }
|
|
702
|
+
const {src:_source,...overrides}=asset;
|
|
703
|
+
asset = {...normalizeAsset(bound.assets[id]),...overrides};
|
|
697
704
|
}
|
|
698
705
|
const source = options.imageResolver?.(asset.src, { asset, path: item.path }) ?? asset.src;
|
|
699
706
|
if (typeof source === "string" && /^data:image\/(png|jpeg|gif|webp);base64,/i.test(source)) {
|
|
@@ -701,8 +708,18 @@ function renderImage(item, box, bound, options) {
|
|
|
701
708
|
href: source, preserveAspectRatio: (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? "Image",
|
|
702
709
|
...traceAttrs(options, item.path) });
|
|
703
710
|
}
|
|
704
|
-
|
|
711
|
+
const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
|
|
712
|
+
const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster data URI or a host imageResolver.';
|
|
713
|
+
if (options.strictAssets) throw new OPFRenderError("unresolved-asset", message, { path: item.path,reason });
|
|
705
714
|
const fill = bound.design.colors.surface;
|
|
715
|
+
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
716
|
+
const padding=Math.min(24*scale,box.width*.06,box.height*.1),inner=inset(box,padding);
|
|
717
|
+
const description=asset.alt??asset.title??'Image';
|
|
718
|
+
const label=`Image unavailable\n${description}`;
|
|
719
|
+
const style=resolveTextStyle({fontFamily:bound.design.fonts.body,fontWeight:600,path:item.path},options.textMeasurement);
|
|
720
|
+
const fit=fitText(label,inner,20*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,textWidthMeasurer(style,options.textMeasurement));
|
|
721
|
+
const textColor=textColorForFill(fill,bound.design.colors.text);
|
|
722
|
+
reportDiagnostic({code:'unresolved-asset',path:item.path,message,reason,source:asset.src,assetId:missingReference,description,placeholder:fit.overflow?'icon':'label'},options);
|
|
706
723
|
const children = [
|
|
707
724
|
tag("rect", {
|
|
708
725
|
x: box.x,
|
|
@@ -713,21 +730,28 @@ function renderImage(item, box, bound, options) {
|
|
|
713
730
|
fill,
|
|
714
731
|
stroke: bound.design.colors.border,
|
|
715
732
|
"stroke-width": 1,
|
|
733
|
+
"stroke-dasharray": "4 3",
|
|
716
734
|
...traceAttrs(options, item.path)
|
|
717
|
-
})
|
|
718
|
-
|
|
735
|
+
})
|
|
736
|
+
];
|
|
737
|
+
if(!fit.overflow)children.push(renderTextBox(label, inner, bound, {
|
|
719
738
|
path: item.path,
|
|
720
739
|
fontSize: 20,
|
|
721
|
-
fontFamily:
|
|
740
|
+
fontFamily: style.fontFamily,
|
|
722
741
|
fontWeight: 600,
|
|
723
|
-
|
|
742
|
+
textStyle:style,fit,diagnosticsHandled:true,
|
|
743
|
+
fill: textColor,
|
|
724
744
|
options,
|
|
725
745
|
align: "center",
|
|
726
746
|
verticalAlign: "middle"
|
|
727
|
-
})
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
747
|
+
}));
|
|
748
|
+
else {
|
|
749
|
+
// This is a status indicator, not shortened authored content. The complete
|
|
750
|
+
// description remains in the accessible name and the path-specific diagnostic.
|
|
751
|
+
const size=Math.max(0,Math.min(inner.width,inner.height,24*scale)),icon=centeredBox(inner,size,size);
|
|
752
|
+
children.push(tag('path',{d:`M ${icon.x} ${icon.y} L ${icon.x+size} ${icon.y+size} M ${icon.x+size} ${icon.y} L ${icon.x} ${icon.y+size}`,fill:'none',stroke:textColor,'stroke-width':Math.min(2*scale,size/8),'aria-hidden':'true'}));
|
|
753
|
+
}
|
|
754
|
+
return tag("g", {...traceAttrs(options,item.path),'data-opf-asset-status':'unresolved',role:'img','aria-label':`Image unavailable: ${description}`},children.join("\n"));
|
|
731
755
|
}
|
|
732
756
|
|
|
733
757
|
function renderMedia(item, box, bound, options) {
|
|
@@ -813,31 +837,32 @@ function renderCode(item, box, bound, options) {
|
|
|
813
837
|
}
|
|
814
838
|
|
|
815
839
|
function renderMetric(item, box, bound, options) {
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
840
|
+
const layout=item.metricLayout;
|
|
841
|
+
if (!layout) throw new OPFRenderError('missing-metric-layout','Metric rendering requires coordinated core metric geometry.',{path:item.path});
|
|
842
|
+
const children=[];
|
|
843
|
+
for (const part of layout.parts) {
|
|
844
|
+
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
845
|
+
if (invalid) throw new OPFRenderError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before rendering.`,{path:part.path});
|
|
846
|
+
if (!part.visible) continue;
|
|
847
|
+
if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFRenderError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,issues:layout.diagnostics});
|
|
848
|
+
const lines=part.fit.sourceLines.map((line,index)=>{
|
|
849
|
+
const origin=part.linePositions[index];
|
|
850
|
+
return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
|
|
851
|
+
'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
|
|
852
|
+
'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
|
|
853
|
+
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
|
|
854
|
+
...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
|
|
855
|
+
'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
|
|
856
|
+
},line.segments.map(segment=>tag('tspan',{x:stableNumber(origin.x+segment.x),
|
|
857
|
+
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
858
|
+
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
859
|
+
},escapeText(part.text.slice(segment.start,segment.end)))).join(''));
|
|
860
|
+
});
|
|
861
|
+
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
|
|
862
|
+
'data-opf-box-x':part.box.x,'data-opf-box-y':part.box.y,'data-opf-box-width':part.box.width,'data-opf-box-height':part.box.height}:{}),
|
|
863
|
+
...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
|
|
864
|
+
}
|
|
865
|
+
return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-metric-container':'true'}:{})},children.join('\n'));
|
|
841
866
|
}
|
|
842
867
|
|
|
843
868
|
function renderQuote(item, box, bound, options) {
|
|
@@ -855,53 +880,19 @@ function renderQuote(item, box, bound, options) {
|
|
|
855
880
|
}
|
|
856
881
|
|
|
857
882
|
function renderTimeline(item, box, bound, options) {
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const
|
|
862
|
-
const
|
|
863
|
-
const
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
y2: y,
|
|
872
|
-
stroke: bound.design.colors.border,
|
|
873
|
-
"stroke-width": 3,
|
|
874
|
-
...traceAttrs(options, item.path)
|
|
875
|
-
})
|
|
876
|
-
];
|
|
877
|
-
|
|
878
|
-
events.forEach((event, index) => {
|
|
879
|
-
const x = start + index * gap;
|
|
880
|
-
const eventPath = `${item.path}.events.${index}`;
|
|
881
|
-
children.push(tag("circle", {
|
|
882
|
-
cx: x,
|
|
883
|
-
cy: y,
|
|
884
|
-
r: Math.min(9, labelWidth / 5, box.height * 0.04),
|
|
885
|
-
fill: bound.design.colors.primary,
|
|
886
|
-
...traceAttrs(options, eventPath)
|
|
887
|
-
}));
|
|
888
|
-
children.push(renderTextBox([event.when, event.what, event.description].filter(Boolean).join("\n"), {
|
|
889
|
-
x: x - labelWidth / 2,
|
|
890
|
-
y: index % 2 === 0 ? box.y : y + 24,
|
|
891
|
-
width: labelWidth,
|
|
892
|
-
height: box.height * 0.38
|
|
893
|
-
}, bound, {
|
|
894
|
-
path: eventPath,
|
|
895
|
-
fontSize: 16,
|
|
896
|
-
fontFamily: bound.design.fonts.body,
|
|
897
|
-
fontWeight: 500,
|
|
898
|
-
fill: bound.design.colors.text,
|
|
899
|
-
options,
|
|
900
|
-
align: "center"
|
|
901
|
-
}));
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
883
|
+
const layout=item.timelineLayout;
|
|
884
|
+
if(!layout)throw new OPFRenderError('missing-timeline-layout','Timeline rendering requires a coordinated core build with shared timeline geometry.',{path:item.path});
|
|
885
|
+
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
886
|
+
const children=[tag('line',{...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
|
|
887
|
+
for(const marker of layout.markers)children.push(tag('circle',{cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
|
|
888
|
+
for(const part of layout.parts){
|
|
889
|
+
if(!part.fit)throw new OPFRenderError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,issues:layout.diagnostics});
|
|
890
|
+
children.push(tag('g',options.trace?{'data-opf-timeline-role':part.role}:{},renderTextBox(part.text,part.box,bound,{
|
|
891
|
+
path:part.path,fit:part.fit,textStyle:part.style,fontFamily:part.requestedStyle.fontFamily,align:part.alignment,
|
|
892
|
+
fill:bound.design.colors.text,diagnosticsHandled:true,options,
|
|
893
|
+
})));
|
|
894
|
+
}
|
|
895
|
+
return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-timeline-arrangement':layout.arrangement}:{})},children.join('\n'));
|
|
905
896
|
}
|
|
906
897
|
|
|
907
898
|
function renderTable(item, box, bound, options) {
|
|
@@ -912,10 +903,11 @@ function renderTable(item, box, bound, options) {
|
|
|
912
903
|
const defaultEdges = [], explicitEdges = [];
|
|
913
904
|
for (const row of layout.rows) for (const cell of row.cells) {
|
|
914
905
|
const style = cell.style ?? {};
|
|
906
|
+
const fill = style.fill ?? (cell.header ? bound.design.colors.primary : bound.design.colors.surface);
|
|
915
907
|
children.push(tag("rect", {
|
|
916
908
|
x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
|
|
917
909
|
width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
|
|
918
|
-
fill
|
|
910
|
+
fill,
|
|
919
911
|
stroke: separateBorders ? undefined : bound.design.colors.border, "stroke-width":separateBorders ? undefined : 1,
|
|
920
912
|
...traceAttrs(options, cell.sourcePath ?? cell.path)
|
|
921
913
|
}));
|
|
@@ -931,7 +923,7 @@ function renderTable(item, box, bound, options) {
|
|
|
931
923
|
children.push((cell.rich ? renderRichTextBox : renderTextBox)(cell.rich ? cell.value : flattenText(cell.value ?? ""), cell.textBox, bound, {
|
|
932
924
|
path:cell.path, fontSize:15, fontFamily:bound.design.fonts.body,
|
|
933
925
|
fontWeight:cell.header ? 700 : 400, textStyle:cell.textStyle, fit:cell.fit,
|
|
934
|
-
fill:style.color ?? (cell.header ? "#FFFFFF" : bound.design.colors.text), align:style.align, options
|
|
926
|
+
fill:style.color ?? textColorForFill(fill, cell.header ? "#FFFFFF" : bound.design.colors.text), align:style.align, options
|
|
935
927
|
}));
|
|
936
928
|
}
|
|
937
929
|
|
|
@@ -976,9 +968,10 @@ function renderImportedChart(item, box, bound, options) {
|
|
|
976
968
|
const chart=item.value, rows=chart.data?.rows??[], columns=chart.data?.columns??[];
|
|
977
969
|
if(!rows.length||columns.length<2)return null;
|
|
978
970
|
const kind=chart.type, circular=['pie','donut','doughnut'].includes(kind);
|
|
979
|
-
const colors=[bound.design.colors.primary,bound.design.colors.secondary,'#9B6BCC','#D98944','#429B85','#CB5D79'];
|
|
971
|
+
const colors=[bound.design.colors.primary,bound.design.colors.secondary,'#9B6BCC','#D98944','#429B85','#CB5D79'].map(color=>chartColorForFill(bound.design.colors.surface,color));
|
|
980
972
|
const children=[];
|
|
981
|
-
const
|
|
973
|
+
const labelColor=textColorForFill(bound.design.colors.surface,bound.design.colors.text);
|
|
974
|
+
const text=(value,rect,path,size=16,align='center')=>renderTextBox(String(value),rect,bound,{path,fontSize:size,fontFamily:bound.design.fonts.body,fontWeight:400,fill:labelColor,options,align,verticalAlign:'middle'});
|
|
982
975
|
const number=value=>typeof value==='number'&&Number.isFinite(value)?value:typeof value==='string'&&value.trim()&&Number.isFinite(Number(value))?Number(value):null;
|
|
983
976
|
const series=columns.slice(1).map((name,j)=>({name,values:rows.map(row=>number(row[j+1]))}));
|
|
984
977
|
children.push(tag('rect',{x:box.x,y:box.y,width:box.width,height:box.height,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}));
|
|
@@ -1030,7 +1023,7 @@ function renderImportedChart(item, box, bound, options) {
|
|
|
1030
1023
|
});
|
|
1031
1024
|
}
|
|
1032
1025
|
});
|
|
1033
|
-
children.push(tag('line',{x1:horizontal?zero:plot.x,x2:horizontal?zero:plot.x+plot.width,y1:horizontal?plot.y:zero,y2:horizontal?plot.y+plot.height:zero,stroke:
|
|
1026
|
+
children.push(tag('line',{x1:horizontal?zero:plot.x,x2:horizontal?zero:plot.x+plot.width,y1:horizontal?plot.y:zero,y2:horizontal?plot.y+plot.height:zero,stroke:labelColor,'stroke-width':1}));
|
|
1034
1027
|
}
|
|
1035
1028
|
return tag('g',traceAttrs(options,item.path),children.join('\n'));
|
|
1036
1029
|
}
|
|
@@ -1045,13 +1038,17 @@ function renderChart(item, box, bound, options) {
|
|
|
1045
1038
|
const data = inlineChartRows(chart.data);
|
|
1046
1039
|
const plot = inset(box, 28);
|
|
1047
1040
|
const max = Math.max(1, ...data.map((row) => Math.abs(row.value)));
|
|
1041
|
+
const panelFill = bound.design.colors.surface;
|
|
1042
|
+
const labelColor = textColorForFill(panelFill, bound.design.colors.text);
|
|
1043
|
+
const primary = chartColorForFill(panelFill, bound.design.colors.primary);
|
|
1044
|
+
const secondary = chartColorForFill(panelFill, bound.design.colors.secondary);
|
|
1048
1045
|
const children = [
|
|
1049
1046
|
tag("rect", {
|
|
1050
1047
|
x: box.x,
|
|
1051
1048
|
y: box.y,
|
|
1052
1049
|
width: box.width,
|
|
1053
1050
|
height: box.height,
|
|
1054
|
-
fill:
|
|
1051
|
+
fill: panelFill,
|
|
1055
1052
|
stroke: bound.design.colors.border,
|
|
1056
1053
|
"stroke-width": 1,
|
|
1057
1054
|
...traceAttrs(options, item.path)
|
|
@@ -1064,7 +1061,7 @@ function renderChart(item, box, bound, options) {
|
|
|
1064
1061
|
fontSize: 20,
|
|
1065
1062
|
fontFamily: bound.design.fonts.body,
|
|
1066
1063
|
fontWeight: 500,
|
|
1067
|
-
fill:
|
|
1064
|
+
fill: labelColor,
|
|
1068
1065
|
options,
|
|
1069
1066
|
align: "center",
|
|
1070
1067
|
verticalAlign: "middle"
|
|
@@ -1081,7 +1078,7 @@ function renderChart(item, box, bound, options) {
|
|
|
1081
1078
|
children.push(tag("polyline", {
|
|
1082
1079
|
points: points.map(([x, y]) => `${stableNumber(x)},${stableNumber(y)}`).join(" "),
|
|
1083
1080
|
fill: "none",
|
|
1084
|
-
stroke:
|
|
1081
|
+
stroke: primary,
|
|
1085
1082
|
"stroke-width": 4,
|
|
1086
1083
|
...traceAttrs(options, `${item.path}.data`)
|
|
1087
1084
|
}));
|
|
@@ -1089,7 +1086,7 @@ function renderChart(item, box, bound, options) {
|
|
|
1089
1086
|
cx: stableNumber(x),
|
|
1090
1087
|
cy: stableNumber(y),
|
|
1091
1088
|
r: 5,
|
|
1092
|
-
fill:
|
|
1089
|
+
fill: primary,
|
|
1093
1090
|
...traceAttrs(options, `${item.path}.data.rows.${index}`)
|
|
1094
1091
|
})));
|
|
1095
1092
|
} else {
|
|
@@ -1104,7 +1101,7 @@ function renderChart(item, box, bound, options) {
|
|
|
1104
1101
|
y: stableNumber(y),
|
|
1105
1102
|
width: stableNumber(barWidth),
|
|
1106
1103
|
height: stableNumber(barHeight),
|
|
1107
|
-
fill: index % 2 === 0 ?
|
|
1104
|
+
fill: index % 2 === 0 ? primary : secondary,
|
|
1108
1105
|
...traceAttrs(options, `${item.path}.data.rows.${index}`)
|
|
1109
1106
|
}));
|
|
1110
1107
|
});
|
|
@@ -1120,7 +1117,7 @@ function renderChart(item, box, bound, options) {
|
|
|
1120
1117
|
fontSize: 12,
|
|
1121
1118
|
fontFamily: bound.design.fonts.body,
|
|
1122
1119
|
fontWeight: 400,
|
|
1123
|
-
fill:
|
|
1120
|
+
fill: labelColor,
|
|
1124
1121
|
options,
|
|
1125
1122
|
align: "center"
|
|
1126
1123
|
}));
|
|
@@ -1150,7 +1147,7 @@ function renderFurniture(bound, presentation, width, height, options, kind) {
|
|
|
1150
1147
|
return tag('g',traceAttrs(options,root),['left','center','right'].map((zone,index)=>{
|
|
1151
1148
|
const item=furniture[zone];if(!item)return '';
|
|
1152
1149
|
const box={x:width*(.07+index*.3),y:kind==='header'?height*.025:height*.925,width:width*.26,height:height*.05};
|
|
1153
|
-
if(item.image)return renderImage({value:item.image,path:`${root}.${zone}.image`},box,bound,options);
|
|
1150
|
+
if(item.image)return renderImage({value:item.image,path:`${root}.${zone}.image`},box,bound,{...options,imageFit:'contain'});
|
|
1154
1151
|
const pieces=[item.text,item.organization?organization?.name:null,item.section?bound.slide.section:null,item.slideNumber?String(bound.index+1):null,typeof item.date==='string'?item.date:null].filter(value=>value!==undefined&&value!==null&&value!=='');
|
|
1155
1152
|
if(item.date===true)reportDiagnostic({code:'date-needs-value',path:`${root}.${zone}.date`,message:'Use a literal date string for a reproducible preview; the document does not define a presentation date.'},options);
|
|
1156
1153
|
const anchor=['start','middle','end'][index],x=index===0?width*.07:index===1?width/2:width*.93;
|
|
@@ -1161,7 +1158,7 @@ function renderBranding(bound,presentation,width,height,options) {
|
|
|
1161
1158
|
const design={...presentation.design,...bound.slide.design}, pieces=[];
|
|
1162
1159
|
const rootFor=key=>bound.slide.design?.[key]!==undefined?`${bound.path}.design.${key}`:`design.${key}`;
|
|
1163
1160
|
if(design.watermark){
|
|
1164
|
-
pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,options)));
|
|
1161
|
+
pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,{...options,imageFit:'contain'})));
|
|
1165
1162
|
}
|
|
1166
1163
|
return pieces.join('');
|
|
1167
1164
|
}
|
|
@@ -1184,32 +1181,50 @@ function renderEmbeddedFonts(fonts = []) {
|
|
|
1184
1181
|
function renderRichTextBox(value, box, bound, config) {
|
|
1185
1182
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
1186
1183
|
const fit=config.fit??fitRichText(value,box,config.fontSize*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:config.fontFamily,fontWeight:config.fontWeight??400,path:config.path},textMeasurement:config.options.textMeasurement});
|
|
1187
|
-
if(fit.overflow){const diagnostic={code:'text-overflow',path:config.path,message:'Mixed-style text exceeds its cell at the minimum font size.'};reportDiagnostic(diagnostic,config.options);if((bound.composition??bound.geometry.composition).overflow==='error')throw new OPFRenderError('layout-overflow',diagnostic.message,{issues:[diagnostic]});}
|
|
1184
|
+
if(fit.overflow&&!config.diagnosticsHandled){const diagnostic={code:'text-overflow',path:config.path,message:'Mixed-style text exceeds its cell at the minimum font size.'};reportDiagnostic(diagnostic,config.options);if((bound.composition??bound.geometry.composition).overflow==='error')throw new OPFRenderError('layout-overflow',diagnostic.message,{issues:[diagnostic]});}
|
|
1188
1185
|
return renderRichLines(value,fit,box,bound,config);
|
|
1189
1186
|
}
|
|
1190
1187
|
|
|
1191
1188
|
function renderRichLines(value,fit,box,bound,config) {
|
|
1192
|
-
const alignment=config.align??bound.design.contentAlignment;
|
|
1189
|
+
const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1193
1190
|
let textOffset=0;
|
|
1194
1191
|
const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
|
|
1195
|
-
|
|
1192
|
+
// With no measurement provider, fragment advances are estimates. Let SVG
|
|
1193
|
+
// shape adjacent runs naturally inside each estimated line instead of turning
|
|
1194
|
+
// those estimates into visible gaps. Supplied measurements keep exact origins.
|
|
1195
|
+
const flow=!config.options.textMeasurement?.measure&&!fit.placement;
|
|
1196
|
+
const content=fit.richLines.map((line,lineIndex)=>{
|
|
1197
|
+
const fragments=line.fragments.map(fragment=>{
|
|
1196
1198
|
const run=fragment.run,offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1197
|
-
const
|
|
1199
|
+
const placed=fit.placement?.lines[lineIndex];
|
|
1200
|
+
// Accepted outline placement owns the horizontal advance. Geometric precision
|
|
1201
|
+
// avoids hinted browser advances; textLength also removes fractional-size
|
|
1202
|
+
// quantization drift. Height and baseline retain the selected font size.
|
|
1203
|
+
const position=flow?{'baseline-shift':fragment.baselineShift?stableNumber(-fragment.baselineShift):undefined}:{x:stableNumber((placed?.x??box.x+offset)+fragment.x),y:stableNumber((placed?.baseline??box.y+line.baseline)+fragment.baselineShift)};
|
|
1204
|
+
const rendered=tag(flow?'tspan':'text',{...(config.options.trace?{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end}:{}),...position,'xml:space':'preserve','text-rendering':flow?undefined:'geometricPrecision',textLength:placed&&fragment.width>0?stableNumber(fragment.width):undefined,lengthAdjust:placed&&fragment.width>0?'spacingAndGlyphs':undefined,'font-family':fontStack(fragment.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(fragment.fontSize),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':flow?'normal':undefined,'text-decoration':[run.underline?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color??'')?run.color:config.fill},escapeText(fragment.text));
|
|
1198
1205
|
if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1199
1206
|
return rendered;
|
|
1200
|
-
|
|
1207
|
+
});
|
|
1208
|
+
if(!flow)return fragments.join('\n');
|
|
1209
|
+
const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
|
|
1210
|
+
const first=line.fragments[0];
|
|
1211
|
+
return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined},fragments.join(''));
|
|
1212
|
+
});
|
|
1201
1213
|
let cursor=0;
|
|
1202
1214
|
const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
|
|
1203
1215
|
const lineTrace=config.options.trace?fit.richLines.map((line,index)=>{
|
|
1204
1216
|
if(index){const newline=/^(\r\n|\r|\n)/.exec(whole.slice(cursor));if(newline)cursor+=newline[0].length;}
|
|
1205
1217
|
const start=cursor;cursor+=(fit.lines[index]??'').length;
|
|
1206
1218
|
const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1207
|
-
|
|
1219
|
+
const placed=fit.placement?.lines[index];
|
|
1220
|
+
return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height};
|
|
1208
1221
|
}):undefined;
|
|
1209
|
-
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'));
|
|
1222
|
+
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),'data-opf-rich-spacing':flow?'natural':'measured'}:{}),...(fit.overflow?{'data-opf-overflow':'true'}:{})},content.join('\n'));
|
|
1210
1223
|
}
|
|
1211
1224
|
|
|
1212
1225
|
function renderTextBox(text, box, bound, config) {
|
|
1226
|
+
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
|
|
1227
|
+
if(invalid)throw new OPFRenderError('invalid-text',`Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which SVG XML cannot represent.`,{path:config.path});
|
|
1213
1228
|
const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
1214
1229
|
const style = config.textStyle ?? resolveTextStyle({fontFamily:config.fontFamily,fontWeight:config.fontWeight ?? 400,italic:config.italic ?? false,path:config.path},config.options.textMeasurement);
|
|
1215
1230
|
const fit = config.fit ?? fitText(String(text ?? ""), box, config.fontSize * scale,
|
|
@@ -1224,16 +1239,30 @@ function renderTextBox(text, box, bound, config) {
|
|
|
1224
1239
|
const totalHeight = fit.lines.length * fit.lineHeight;
|
|
1225
1240
|
const startY = config.verticalAlign === "middle"
|
|
1226
1241
|
? box.y + Math.max(0, (box.height - totalHeight) / 2) + size : box.y + size;
|
|
1227
|
-
const alignment=config.align??bound.design.contentAlignment;
|
|
1242
|
+
const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1228
1243
|
const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
|
|
1229
1244
|
const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
|
|
1230
|
-
const lines = fit.lines.map((line, index) =>
|
|
1231
|
-
|
|
1232
|
-
|
|
1245
|
+
const lines = fit.lines.map((line, index) => {
|
|
1246
|
+
const sourceLine=fit.sourceLines?.[index],placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1247
|
+
const origin=placed?.x??x-(sourceLine?.width??0)*factor;
|
|
1248
|
+
const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
|
|
1249
|
+
const content=tabs?sourceLine.segments.map(segment=>tag('tspan',{
|
|
1250
|
+
x:stableNumber(origin+segment.x),textLength:segment.kind==='tab'||placed?stableNumber(segment.width):undefined,
|
|
1251
|
+
lengthAdjust:segment.kind==='tab'||placed?'spacingAndGlyphs':undefined,
|
|
1252
|
+
...(config.options.trace?{'data-opf-source-start':segment.start,'data-opf-source-end':segment.end,'data-opf-segment':segment.kind}:{}),
|
|
1253
|
+
},escapeText(line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start)))).join(''):escapeText(line);
|
|
1254
|
+
return tag("text", {
|
|
1255
|
+
x: stableNumber(tabs?origin:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
|
|
1256
|
+
"text-anchor": tabs?'start':anchor, "font-family": fontStack(style.fontFamily, config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type),
|
|
1233
1257
|
"font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
|
|
1234
|
-
|
|
1235
|
-
|
|
1258
|
+
'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
|
|
1259
|
+
textLength:!tabs&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!tabs&&placed?.width>0?'spacingAndGlyphs':undefined,
|
|
1260
|
+
...traceAttrs(config.options, config.path),
|
|
1261
|
+
...(config.options.trace&&sourceLine?{'data-opf-source-start':sourceLine.start,'data-opf-source-end':sourceLine.end,'data-opf-source-next-start':sourceLine.nextStart,'data-opf-line-boundary':sourceLine.boundary}:{}),
|
|
1262
|
+
}, content);
|
|
1263
|
+
});
|
|
1236
1264
|
return tag("g", { ...traceAttrs(config.options, config.path),
|
|
1265
|
+
...(config.options.trace&&fit.sourceLines?{'data-opf-source-text':'true','data-opf-box-x':box.x,'data-opf-box-y':box.y,'data-opf-box-width':box.width,'data-opf-box-height':box.height}:{}),
|
|
1237
1266
|
...(fit.overflow ? { "data-opf-overflow": "true" } : {}) }, lines.join("\n"));
|
|
1238
1267
|
}
|
|
1239
1268
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": "
|
|
9
|
+
"node": "24.x"
|
|
10
10
|
},
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
@@ -55,9 +55,9 @@
|
|
|
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 && 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 && node --check test/rich-table.mjs && node --check test/styled-table.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 && 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 && node --check test/rich-table.mjs && node --check test/styled-table.mjs && node --check test/chart-colors.mjs && node --check test/image-placeholders.mjs && node --check test/image-placeholders-browser.mjs && node --check test/accepted-text.mjs && node --check test/accepted-text-fixtures.mjs && node --check test/accepted-text-browser.mjs && node --check test/rich-spacing-browser.mjs && node --check test/rich-flow-browser.mjs && node --check test/rich-flow-review-sheets.mjs",
|
|
59
59
|
"validate": "node scripts/validate-package.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/quote-footer.mjs && node test/shared-quote.mjs && node test/rich-trace.mjs && node test/rich-table.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 && npm run test:styled-table",
|
|
60
|
+
"test": "npm run build && node test/font-outlines.mjs && node test/accepted-text.mjs && node test/image-placeholders.mjs && node test/chart-colors.mjs && 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/quote-footer.mjs && node test/shared-quote.mjs && node test/rich-trace.mjs && node test/rich-table.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 && npm run test:styled-table",
|
|
61
61
|
"test:golden": "npm run build && node test/golden.mjs",
|
|
62
62
|
"test:packed": "node test/packed-install.mjs",
|
|
63
63
|
"test:browser": "npm run build:browser-check && node test/browser-check.mjs",
|
|
@@ -66,7 +66,18 @@
|
|
|
66
66
|
"build:browser-check": "node scripts/build-browser-check.mjs",
|
|
67
67
|
"test:styled-table": "node test/styled-table.mjs",
|
|
68
68
|
"test:code": "node test/shared-code.mjs",
|
|
69
|
-
"test:code-browser": "node test/shared-code-browser.mjs"
|
|
69
|
+
"test:code-browser": "node test/shared-code-browser.mjs",
|
|
70
|
+
"test:metric": "node test/shared-metric.mjs",
|
|
71
|
+
"test:images-browser": "node test/image-placeholders-browser.mjs",
|
|
72
|
+
"test:font-preparation": "node test/font-preparation.mjs artifacts/font-preparation.json",
|
|
73
|
+
"test:font-preparation-browser": "node test/font-preparation-browser.mjs artifacts/font-preparation-browser.json",
|
|
74
|
+
"test:font-variants": "node test/font-variants.mjs",
|
|
75
|
+
"test:font-variants-browser": "node test/font-variants-browser.mjs",
|
|
76
|
+
"test:text": "node test/font-outlines.mjs && node test/accepted-text.mjs",
|
|
77
|
+
"test:text-browser": "node test/accepted-text-browser.mjs",
|
|
78
|
+
"test:rich-spacing-browser": "node test/rich-spacing-browser.mjs",
|
|
79
|
+
"test:rich-flow-browser": "node test/rich-flow-browser.mjs",
|
|
80
|
+
"test:plain-whitespace-browser": "node test/plain-whitespace-browser.mjs"
|
|
70
81
|
},
|
|
71
82
|
"dependencies": {
|
|
72
83
|
"@expo-google-fonts/arimo": "0.4.3",
|
|
@@ -74,10 +85,10 @@
|
|
|
74
85
|
"@expo-google-fonts/carlito": "0.4.1",
|
|
75
86
|
"@expo-google-fonts/cousine": "0.4.3",
|
|
76
87
|
"@expo-google-fonts/gelasio": "0.4.1",
|
|
77
|
-
"@expo-google-fonts/roboto": "
|
|
78
|
-
"@expo-google-fonts/roboto-mono": "
|
|
88
|
+
"@expo-google-fonts/roboto": "0.4.3",
|
|
89
|
+
"@expo-google-fonts/roboto-mono": "0.4.2",
|
|
79
90
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
80
|
-
"@openpresentation/opf": "^0.
|
|
91
|
+
"@openpresentation/opf": "^0.10.0",
|
|
81
92
|
"@resvg/resvg-js": "^2.6.2",
|
|
82
93
|
"fontkit": "^2.0.4",
|
|
83
94
|
"pdf-lib": "^1.17.1",
|