@openpresentation/opf-render 0.7.0 → 0.8.1

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/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, presentation, 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 surface=bound.design.contentBox && !['title','subtitle','tag'].includes(item.field) ? tag('rect',{x:item.box.x,y:item.box.y,width:item.box.width,height:item.box.height,rx:8,fill:bound.design.colors.surface,stroke:bound.design.colors.border}) : '';
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
- asset = normalizeAsset(bound.assets[id]);
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
- if (options.strictAssets) throw new OPFRenderError("unresolved-asset", "Image requires an embedded raster data URI or a host imageResolver.", { path: item.path });
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
- renderTextBox(asset.alt || asset.title || asset.src || "Image", inset(box, 24), bound, {
735
+ })
736
+ ];
737
+ if(!fit.overflow)children.push(renderTextBox(label, inner, bound, {
719
738
  path: item.path,
720
739
  fontSize: 20,
721
- fontFamily: bound.design.fonts.body,
740
+ fontFamily: style.fontFamily,
722
741
  fontWeight: 600,
723
- fill: bound.design.colors.mutedText,
742
+ textStyle:style,fit,diagnosticsHandled:true,
743
+ fill: textColor,
724
744
  options,
725
745
  align: "center",
726
746
  verticalAlign: "middle"
727
- })
728
- ];
729
-
730
- return tag("g", traceAttrs(options, item.path), children.join("\n"));
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 metric = isPlainObject(item.value) ? item.value : { value: item.value };
817
- const children = [
818
- renderTextBox(String(metric.value ?? ""), { ...box, height: box.height * 0.45 }, bound, {
819
- path: `${item.path}.value`,
820
- fontSize: Math.min(76, box.height * 0.28),
821
- fontFamily: bound.design.fonts.heading,
822
- fontWeight: 800,
823
- fill: bound.design.colors.primary,
824
- options
825
- }),
826
- renderTextBox([metric.label, metric.description, metric.delta].filter(Boolean).join("\n"), {
827
- x: box.x,
828
- y: box.y + box.height * 0.45,
829
- width: box.width,
830
- height: box.height * 0.55
831
- }, bound, {
832
- path: item.path,
833
- fontSize: 23,
834
- fontFamily: bound.design.fonts.body,
835
- fontWeight: 500,
836
- fill: bound.design.colors.text,
837
- options
838
- })
839
- ];
840
- return tag("g", traceAttrs(options, item.path), children.join("\n"));
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 timeline = Array.isArray(item.value) ? { events: item.value } : item.value;
859
- const events = Array.isArray(timeline?.events) ? timeline.events : [];
860
- if (!events.length) return "";
861
- const labelWidth = box.width / Math.max(2, events.length);
862
- const gap = (box.width - labelWidth) / Math.max(1, events.length - 1);
863
- const start = events.length === 1 ? box.x + box.width / 2 : box.x + labelWidth / 2;
864
- const end = events.length === 1 ? start : box.x + box.width - labelWidth / 2;
865
- const y = box.y + box.height * 0.46;
866
- const children = [
867
- tag("line", {
868
- x1: start,
869
- x2: end,
870
- y1: y,
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: style.fill ?? (cell.header ? bound.design.colors.primary : bound.design.colors.surface),
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 text=(value,rect,path,size=16,align='center')=>renderTextBox(String(value),rect,bound,{path,fontSize:size,fontFamily:bound.design.fonts.body,fontWeight:400,fill:bound.design.colors.text,options,align,verticalAlign:'middle'});
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:bound.design.colors.text,'stroke-width':1}));
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: "#FFFFFF",
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: bound.design.colors.mutedText,
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: bound.design.colors.primary,
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: bound.design.colors.primary,
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 ? bound.design.colors.primary : bound.design.colors.secondary,
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: bound.design.colors.mutedText,
1120
+ fill: labelColor,
1124
1121
  options,
1125
1122
  align: "center"
1126
1123
  }));
@@ -1141,27 +1138,23 @@ function inlineChartRows(data) {
1141
1138
  }
1142
1139
 
1143
1140
  function renderFurniture(bound, presentation, width, height, options, kind) {
1144
- const local=bound.slide.design?.[kind] !== undefined;
1145
- const furniture=local?bound.slide.design[kind]:presentation.design?.[kind];
1146
- if(!furniture)return "";
1147
- const root=local?`${bound.path}.design.${kind}`:`design.${kind}`;
1148
- const organizations=Array.isArray(presentation.organization)?presentation.organization:[presentation.organization];
1149
- const organization=organizations.find(item=>item?.role==='primary')??organizations.find(Boolean);
1150
- return tag('g',traceAttrs(options,root),['left','center','right'].map((zone,index)=>{
1151
- const item=furniture[zone];if(!item)return '';
1152
- 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);
1154
- 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
- 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
- const anchor=['start','middle','end'][index],x=index===0?width*.07:index===1?width/2:width*.93;
1157
- return tag('text',{x:stableNumber(x),y:stableNumber(kind==='header'?height*.05:height-28),'text-anchor':anchor,'font-family':fontStack(bound.design.fonts.body,bound.design.fontScheme.type),'font-size':13,fill:bound.design.colors.mutedText,...traceAttrs(options,`${root}.${zone}`)},escapeText(pieces.join(' · ')));
1141
+ const layout=bound.geometry.furniture;
1142
+ if(!layout){
1143
+ const value=bound.slide.design?.[kind]!==undefined?bound.slide.design[kind]:presentation.design?.[kind];
1144
+ if(value)throw new OPFRenderError('missing-furniture-layout','Header/footer rendering requires coordinated core furniture geometry.',{path:bound.path});
1145
+ return '';
1146
+ }
1147
+ return tag('g',{},layout.parts.filter(part=>part.kind===kind).map(part=>{
1148
+ const trace=options.trace?{'data-opf-furniture-kind':kind,'data-opf-furniture-field':part.field,'data-opf-furniture-generated':String(part.generated),'data-opf-furniture-editable':!part.generated?'true':undefined,'data-opf-furniture-source':part.sourcePath}:{};
1149
+ if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain'}));
1150
+ return tag('g',trace,renderTextBox(part.text,part.box,bound,{path:part.path,fit:part.fit,textStyle:part.style,fontFamily:part.style.fontFamily,align:part.alignment,fill:bound.design.colors.mutedText,diagnosticsHandled:true,options}));
1158
1151
  }).join(''));
1159
1152
  }
1160
1153
  function renderBranding(bound,presentation,width,height,options) {
1161
1154
  const design={...presentation.design,...bound.slide.design}, pieces=[];
1162
1155
  const rootFor=key=>bound.slide.design?.[key]!==undefined?`${bound.path}.design.${key}`:`design.${key}`;
1163
1156
  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)));
1157
+ 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
1158
  }
1166
1159
  return pieces.join('');
1167
1160
  }
@@ -1184,32 +1177,50 @@ function renderEmbeddedFonts(fonts = []) {
1184
1177
  function renderRichTextBox(value, box, bound, config) {
1185
1178
  const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
1186
1179
  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]});}
1180
+ 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
1181
  return renderRichLines(value,fit,box,bound,config);
1189
1182
  }
1190
1183
 
1191
1184
  function renderRichLines(value,fit,box,bound,config) {
1192
- const alignment=config.align??bound.design.contentAlignment;
1185
+ const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1193
1186
  let textOffset=0;
1194
1187
  const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
1195
- const content=fit.richLines.flatMap(line=>line.fragments.map(fragment=>{
1188
+ // With no measurement provider, fragment advances are estimates. Let SVG
1189
+ // shape adjacent runs naturally inside each estimated line instead of turning
1190
+ // those estimates into visible gaps. Supplied measurements keep exact origins.
1191
+ const flow=!config.options.textMeasurement?.measure&&!fit.placement;
1192
+ const content=fit.richLines.map((line,lineIndex)=>{
1193
+ const fragments=line.fragments.map(fragment=>{
1196
1194
  const run=fragment.run,offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1197
- const rendered=tag('text',{...(config.options.trace?{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end}:{}),x:stableNumber(box.x+offset+fragment.x),y:stableNumber(box.y+line.baseline+fragment.baselineShift),'xml:space':'preserve','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':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));
1195
+ const placed=fit.placement?.lines[lineIndex];
1196
+ // Accepted outline placement owns the horizontal advance. Geometric precision
1197
+ // avoids hinted browser advances; textLength also removes fractional-size
1198
+ // quantization drift. Height and baseline retain the selected font size.
1199
+ 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)};
1200
+ 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
1201
  if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1199
1202
  return rendered;
1200
- }));
1203
+ });
1204
+ if(!flow)return fragments.join('\n');
1205
+ const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
1206
+ const first=line.fragments[0];
1207
+ 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(''));
1208
+ });
1201
1209
  let cursor=0;
1202
1210
  const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
1203
1211
  const lineTrace=config.options.trace?fit.richLines.map((line,index)=>{
1204
1212
  if(index){const newline=/^(\r\n|\r|\n)/.exec(whole.slice(cursor));if(newline)cursor+=newline[0].length;}
1205
1213
  const start=cursor;cursor+=(fit.lines[index]??'').length;
1206
1214
  const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1207
- return {start,end:cursor,x:box.x+offset,y:box.y+line.y,height:line.height};
1215
+ const placed=fit.placement?.lines[index];
1216
+ return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height};
1208
1217
  }):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'));
1218
+ 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
1219
  }
1211
1220
 
1212
1221
  function renderTextBox(text, box, bound, config) {
1222
+ const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
1223
+ 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
1224
  const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
1214
1225
  const style = config.textStyle ?? resolveTextStyle({fontFamily:config.fontFamily,fontWeight:config.fontWeight ?? 400,italic:config.italic ?? false,path:config.path},config.options.textMeasurement);
1215
1226
  const fit = config.fit ?? fitText(String(text ?? ""), box, config.fontSize * scale,
@@ -1224,16 +1235,30 @@ function renderTextBox(text, box, bound, config) {
1224
1235
  const totalHeight = fit.lines.length * fit.lineHeight;
1225
1236
  const startY = config.verticalAlign === "middle"
1226
1237
  ? box.y + Math.max(0, (box.height - totalHeight) / 2) + size : box.y + size;
1227
- const alignment=config.align??bound.design.contentAlignment;
1238
+ const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1228
1239
  const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
1229
1240
  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) => tag("text", {
1231
- x: stableNumber(x), y: stableNumber(startY + index * fit.lineHeight),
1232
- "text-anchor": anchor, "font-family": fontStack(style.fontFamily, config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type),
1241
+ const lines = fit.lines.map((line, index) => {
1242
+ const sourceLine=fit.sourceLines?.[index],placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1243
+ const origin=placed?.x??x-(sourceLine?.width??0)*factor;
1244
+ const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
1245
+ const content=tabs?sourceLine.segments.map(segment=>tag('tspan',{
1246
+ x:stableNumber(origin+segment.x),textLength:segment.kind==='tab'||placed?stableNumber(segment.width):undefined,
1247
+ lengthAdjust:segment.kind==='tab'||placed?'spacingAndGlyphs':undefined,
1248
+ ...(config.options.trace?{'data-opf-source-start':segment.start,'data-opf-source-end':segment.end,'data-opf-segment':segment.kind}:{}),
1249
+ },escapeText(line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start)))).join(''):escapeText(line);
1250
+ return tag("text", {
1251
+ x: stableNumber(tabs?origin:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
1252
+ "text-anchor": tabs?'start':anchor, "font-family": fontStack(style.fontFamily, config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type),
1233
1253
  "font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
1234
- ...traceAttrs(config.options, config.path)
1235
- }, escapeText(line)));
1254
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
1255
+ textLength:!tabs&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!tabs&&placed?.width>0?'spacingAndGlyphs':undefined,
1256
+ ...traceAttrs(config.options, config.path),
1257
+ ...(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}:{}),
1258
+ }, content);
1259
+ });
1236
1260
  return tag("g", { ...traceAttrs(config.options, config.path),
1261
+ ...(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
1262
  ...(fit.overflow ? { "data-opf-overflow": "true" } : {}) }, lines.join("\n"));
1238
1263
  }
1239
1264
 
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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.0"
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 && node --check test/furniture.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/furniture.mjs && 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": "^0.4.3",
78
- "@expo-google-fonts/roboto-mono": "^0.4.2",
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.9.0",
91
+ "@openpresentation/opf": "^0.10.1",
81
92
  "@resvg/resvg-js": "^2.6.2",
82
93
  "fontkit": "^2.0.4",
83
94
  "pdf-lib": "^1.17.1",