@openpresentation/opf-pptx 0.10.0 → 0.11.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/DEPENDENCY-NOTES.md +1 -1
- package/README.md +5 -3
- package/dist/font-weights.js +63 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +61 -9
- package/dist/media-dedupe.js +44 -0
- package/dist/native-text-style.js +16 -2
- package/dist/watermark-provenance.js +146 -0
- package/package.json +7 -6
package/DEPENDENCY-NOTES.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Coordinated `@openpresentation/opf` pin
|
|
4
4
|
|
|
5
|
-
PPTX 0.
|
|
5
|
+
PPTX 0.11.1 depends on published [`@openpresentation/opf@^0.11.2`](https://www.npmjs.com/package/@openpresentation/opf) (reference layer: ColorRef, document `variables`, exported `resolveColorRef()`). The optional renderer peer is [`@openpresentation/opf-render@^0.11.0`](https://www.npmjs.com/package/@openpresentation/opf-render) so editor 0.10.0 + render 0.11.0 + pptx 0.11.0 install together. CI checks out OPF [`b8a1faf`](https://github.com/OpenPresentation/opf/commit/b8a1faf24203635bd36b09ea19f44bcd53781f16) (`opf-v0.11.0`) for coordinated source linking. Named content colors resolve through core `resolveColorRef()`; eight-digit hex alpha stays a local export concern because core `normalizeHexColor` drops the alpha byte. Native `schemeClr` / theme `clrScheme` writes remain follow-up work.
|
|
6
6
|
|
|
7
7
|
OPF PPTX 0.5.1 ships the exact, unmodified PptxGenJS 4.0.1 ESM distribution in `vendor/pptxgenjs`, with its MIT license, upstream archive integrity and per-file SHA-256 hashes. Its actual JSZip dependency is declared directly. Ordinary npm installations therefore omit the unused image-size parser without requiring consumer overrides. This removes the affected dependency; it does not patch the parser. Published OPF PPTX 0.5.0 retains the older graph.
|
|
8
8
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OPF PPTX
|
|
2
2
|
|
|
3
|
-
Version 0.
|
|
3
|
+
Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
|
|
4
4
|
|
|
5
5
|
Version 0.9.1 kept core `^0.11.0` and raised the optional `@openpresentation/opf-render` peer to `^0.9.0` so it coexists with editor 0.8.0. ColorRef / `variables` on content colors still hex-resolve through core `resolveColorRef()` before PptxGenJS `srgbClr` export. Unrecognized run colors such as `color:'invalid'` still validate and fall back to the theme text color. Native DrawingML `schemeClr` and theme `clrScheme` writes, and native `p:hf` headers/footers, are not in this release. Import still flattens theme colors to hex. Metric, quote and timeline layout placeholders and the corrected text-bullet contract from 0.8.1 are retained.
|
|
6
6
|
|
|
@@ -103,7 +103,7 @@ This intentionally tightens the previous host-dependent `Date` parsing contract.
|
|
|
103
103
|
|
|
104
104
|
### New in 0.10.0: current native body formatting
|
|
105
105
|
|
|
106
|
-
Current source imports supported formatting from ordinary untagged native body text and list items. A value that previously imported as a string can now be a rich-run array containing the same current characters with explicit native properties. Unstyled values remain strings; title/subtitle selection and tagged recovery stay separate. Run, field and break order, blank paragraphs, significant whitespace, explicit normal overrides, paragraph/list defaults, point sizes, Latin font families, supported colors/alpha, hyperlinks and script direction come from the current PPTX. Edits, clears and deletion remain authoritative in every provenance mode.
|
|
106
|
+
Current source imports supported formatting from ordinary untagged native body text and list items. A value that previously imported as a string can now be a rich-run array containing the same current characters with explicit native properties. Unstyled values remain strings; title/subtitle selection and tagged recovery stay separate. Run, field and break order, blank paragraphs, significant whitespace, explicit normal overrides, paragraph/list defaults, point sizes, Latin font families, supported colors/alpha, hyperlinks and script direction come from the current PPTX. Edits, clears and deletion remain authoritative in every provenance mode. Native weight faces of the bundled Roboto family (for example `Roboto SemiBold`) import as `Roboto` plus `bold` for weights of 600 and above. Medium (500) and lighter weights import as regular, because OPF runs have no numeric weight, so a round trip re-exports Medium as Regular; `approximate-body-font-weight` reports this. Authored families such as `Arial Black` or `Aptos Light` keep their names.
|
|
107
107
|
|
|
108
108
|
This does not reconstruct original source run identities or boundaries between native shapes, infer master/layout text styles, or recover cached authored content. Native paragraph/break boundaries become LF; character-reference CR remains explicit. Unsupported properties report body-path diagnostics rather than claim exact formatting. Reading order still uses current native positions. Published npm `0.9.1` does not contain this representation change; portable conversion checks do not establish Office rendering or general rich-text round-trip fidelity.
|
|
109
109
|
|
|
@@ -148,7 +148,7 @@ When the package carries an FF-32 stored `language` ([document round trip](docs/
|
|
|
148
148
|
|
|
149
149
|
Exports report `language-unresolved` when the document's language cannot be resolved locally (a URL, `pkg:` reference or unknown id) and `en-US` is used.
|
|
150
150
|
|
|
151
|
-
**Core without the resolver.** Core `@openpresentation/opf` 0.11.0 and earlier have no `resolveScriptFonts` (this release requires ^0.11.
|
|
151
|
+
**Core without the resolver.** Core `@openpresentation/opf` 0.11.0 and earlier have no `resolveScriptFonts` (this release requires ^0.11.2, so this only applies to a forced older core). Export with it is byte-identical to the output before FF-07 (`lang="en-US"`, empty theme `ea`/`cs`, no `rtl`), and a document that names a language gets a `language-export-unavailable` diagnostic. A core with the resolver but without `paragraphDirection` marks no paragraph direction and reports `paragraph-direction-unavailable` for a right-to-left deck. Import then matches run tags against the installed catalog's `bcp47` and primary language. `npm run test:packed` exercises this path against the registry release. CI links core at a pinned commit that has the resolver.
|
|
152
152
|
|
|
153
153
|
## Runtime Policy
|
|
154
154
|
|
|
@@ -222,6 +222,8 @@ Slide-image treatments export from core's normalized geometry as native DrawingM
|
|
|
222
222
|
- `recolor` becomes `a:grayscl` or `a:duotone`, followed by `a:alphaModFix` for `opacity`, on the blip only.
|
|
223
223
|
- `overlay` becomes one tagged `OPF slide image overlay slides.N` shape directly above the picture.
|
|
224
224
|
|
|
225
|
+
A deck or slide watermark (`design.watermark`) exports as one native picture named `OPF watermark` per slide, after the slide image and its overlay, if any, and before all content (the preview's paint order, so a background slide image lies beneath the watermark), at the preview's frame: the centered 40% by 40% box at 30%/30% of the slide, fitted without cropping. Its opacity is `a:alphaModFix` (the object form's `opacity`, otherwise the preview default 0.08), and its alt text is the asset's `alt`, otherwise `Watermark`. `design.watermark = false` on a slide exports none. An unchanged export imports back as `design.watermark` (at deck level when every slide carries the same one); an edited picture stays ordinary content and reports `invalid-watermark-provenance`. An unresolved image reports `unresolved-asset` and an object with no `src` reports `watermark-not-exported`.
|
|
226
|
+
|
|
225
227
|
An unchanged export imports every treatment field back. An edited overlay drops only the overlay and reports `invalid-slide-image-provenance`. An edited picture or effect drops the slide image. See core `docs/image-treatments.md` for the vocabulary and the unsupported effects: blur, shadows, soft edges and background removal. PowerPoint's luminance weights for grayscale and duotone are unverified natively.
|
|
226
228
|
|
|
227
229
|
Tests compare SVG/native fit and crop geometry across nine synthetic raster fixtures and cover all eight JPEG orientations. Keynote 14.4 visually preserves proportions for wide/tall fit/crop and displays all eight orientations correctly. This does not establish Microsoft PowerPoint raster parity or WebP support in every Office version.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// One table of the weight and posture words that native legacy font families use
|
|
2
|
+
// as style-link names ("Roboto SemiBold"). The exporter recognizes them to keep
|
|
3
|
+
// a chosen family's own faces (sameTypeface) and the importer uses the same
|
|
4
|
+
// table to map such a face back to family plus OPF weight (splitWeightFace).
|
|
5
|
+
// Weights are CSS numeric weights. Words are compact; a two-word spelling
|
|
6
|
+
// ("semi bold") is the same word split by one space.
|
|
7
|
+
export const FONT_WEIGHT_WORDS = Object.freeze([
|
|
8
|
+
['thin', 100], ['hairline', 100],
|
|
9
|
+
['extralight', 200], ['ultralight', 200], ['light', 300], ['semilight', 350], ['demilight', 350],
|
|
10
|
+
['book', 400], ['regular', 400], ['normal', 400], ['medium', 500],
|
|
11
|
+
['semibold', 600], ['demibold', 600], ['bold', 700], ['extrabold', 800], ['ultrabold', 800],
|
|
12
|
+
['black', 900], ['heavy', 900], ['extrablack', 950], ['ultrablack', 950],
|
|
13
|
+
]);
|
|
14
|
+
export const FONT_POSTURE_WORDS = Object.freeze(['italic', 'oblique']);
|
|
15
|
+
|
|
16
|
+
const weights = new Map(FONT_WEIGHT_WORDS);
|
|
17
|
+
const postures = new Set(FONT_POSTURE_WORDS);
|
|
18
|
+
const isWord = word => weights.has(word) || postures.has(word);
|
|
19
|
+
|
|
20
|
+
// Tokenize a style suffix in one linear pass, without regular expressions:
|
|
21
|
+
// this runs on typeface names from untrusted PPTX files. A word is one
|
|
22
|
+
// whitespace-separated part ("SemiBold"), or two adjacent parts that join into a
|
|
23
|
+
// word ("Semi Bold"). Returns the lower-case words, or undefined when any part
|
|
24
|
+
// is not a style word.
|
|
25
|
+
function styleWords(suffix) {
|
|
26
|
+
const parts = suffix.toLowerCase().split(/[ \t]+/).filter(Boolean);
|
|
27
|
+
if (!parts.length) return undefined;
|
|
28
|
+
const words = [];
|
|
29
|
+
for (let index = 0; index < parts.length; index++) {
|
|
30
|
+
if (isWord(parts[index])) words.push(parts[index]);
|
|
31
|
+
else if (index + 1 < parts.length && isWord(parts[index] + parts[index + 1])) words.push(parts[index] + parts[++index]);
|
|
32
|
+
else return undefined;
|
|
33
|
+
}
|
|
34
|
+
return words;
|
|
35
|
+
}
|
|
36
|
+
// True when the whole suffix is made only of style words, e.g. "SemiBold", "Extra Bold Italic".
|
|
37
|
+
export const isFaceStyleSuffix = suffix => styleWords(suffix) !== undefined;
|
|
38
|
+
|
|
39
|
+
// Families whose weight faces the exporter itself writes under native style-link
|
|
40
|
+
// names: the bundled families a provider resolves by legacy weight name. Other
|
|
41
|
+
// families ("Aptos Light", a theme font that is itself "Roboto Light") are
|
|
42
|
+
// authored typefaces and are never split.
|
|
43
|
+
export const KNOWN_WEIGHT_FACE_FAMILIES = Object.freeze(['Roboto']);
|
|
44
|
+
|
|
45
|
+
// Split "<family> <weight words>" into the family and its CSS weight. Returns
|
|
46
|
+
// undefined for any other name, so authored families are never altered.
|
|
47
|
+
export function splitWeightFace(typeface, families = KNOWN_WEIGHT_FACE_FAMILIES) {
|
|
48
|
+
if (typeof typeface !== 'string') return undefined;
|
|
49
|
+
const name = typeface.trim();
|
|
50
|
+
for (const family of families) {
|
|
51
|
+
const base = family.trim();
|
|
52
|
+
if (name.length <= base.length + 1 || !name.toLowerCase().startsWith(base.toLowerCase()) || !/[ \t]/.test(name[base.length])) continue;
|
|
53
|
+
const words = styleWords(name.slice(base.length + 1));
|
|
54
|
+
if (!words) continue;
|
|
55
|
+
let weight, italic = false;
|
|
56
|
+
for (const word of words) {
|
|
57
|
+
if (postures.has(word)) italic = true;
|
|
58
|
+
else weight = weights.get(word);
|
|
59
|
+
}
|
|
60
|
+
return {family: base, weight: weight ?? 400, italic};
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface ChartDataUnplottableDiagnostic { code: "chart-data-unplottable"
|
|
|
40
40
|
/** Content with no PowerPoint export (an empty table, an unsupported payload) is replaced by a plain-language placeholder frame. */
|
|
41
41
|
export interface ContentPlaceholderDiagnostic { code: "content-placeholder"; path: string; message: string; reason: "table-has-no-rows" | "unsupported-payload" }
|
|
42
42
|
/** The chart data was reshaped to export a native chart: a histogram's values were binned, or a single value column was plotted against row numbers. */
|
|
43
|
+
export interface WatermarkNotExportedDiagnostic { code: "watermark-not-exported"; path: string; message: string }
|
|
43
44
|
export interface ChartDataAdaptedDiagnostic { code: "chart-data-adapted"; path: string; message: string; adaptation: "histogram-binned" | "row-numbers" }
|
|
44
45
|
|
|
45
46
|
export interface ToPptxOptions {
|
|
@@ -57,7 +58,7 @@ export interface ToPptxOptions {
|
|
|
57
58
|
/** Match preview/pagination clearance around supplied vector text outlines; default 1. */
|
|
58
59
|
textRasterPadding?: number;
|
|
59
60
|
/** Layout diagnostics, `media-provenance-omitted` when video data cannot be stored, plus `unresolved-font-scheme` (once per reference path) when a font-scheme id matches no record and the default `aptos` scheme is used as the base. */
|
|
60
|
-
onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ContentPlaceholderDiagnostic) => void;
|
|
61
|
+
onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ContentPlaceholderDiagnostic | WatermarkNotExportedDiagnostic) => void;
|
|
61
62
|
baseDir?: string;
|
|
62
63
|
compressionLevel?: number;
|
|
63
64
|
imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {nativeBodyReader, joinNativeParagraphs} from './body-text-import.js';
|
|
2
|
+
import {isFaceStyleSuffix} from './font-weights.js';
|
|
2
3
|
import {importTableFrames} from './table-import.js';
|
|
3
4
|
import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
|
|
4
5
|
import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
|
|
@@ -14,6 +15,8 @@ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, r
|
|
|
14
15
|
import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
|
|
15
16
|
import {importImageOrientation} from './image-import.js';
|
|
16
17
|
import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
|
|
18
|
+
import {dedupeMedia} from './media-dedupe.js';
|
|
19
|
+
import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
|
|
17
20
|
import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
|
|
18
21
|
import {importBackground} from './background-import.js';
|
|
19
22
|
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
@@ -204,6 +207,7 @@ export async function toPptx(input, options = {}) {
|
|
|
204
207
|
context.tableCells = new Map();
|
|
205
208
|
context.imagePlacements = new Map();
|
|
206
209
|
context.slideImages = new Map();
|
|
210
|
+
context.watermarks = new Map();
|
|
207
211
|
context.backgroundFills = new Map();
|
|
208
212
|
context.notesWithCarriageReturns = new Map();
|
|
209
213
|
context.cardTags = new Map();
|
|
@@ -308,6 +312,12 @@ export async function fromPptx(input, options = {}) {
|
|
|
308
312
|
furnitureContexts[index].mediaRegistry = mediaRegistry;
|
|
309
313
|
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
|
|
310
314
|
}
|
|
315
|
+
// A watermark carried identically by every slide is the deck's design.watermark.
|
|
316
|
+
const carried = imported.slides.map(slide => slide.design?.watermark);
|
|
317
|
+
if (carried.length && carried[0] && carried.every(value => JSON.stringify(value) === JSON.stringify(carried[0]))) {
|
|
318
|
+
imported.design = {...imported.design, watermark: carried[0]};
|
|
319
|
+
for (const slide of imported.slides) { delete slide.design.watermark; if (!Object.keys(slide.design).length) delete slide.design; }
|
|
320
|
+
}
|
|
311
321
|
// Conflicting media asset IDs fall back to their current native URLs during import.
|
|
312
322
|
for (const context of furnitureContexts) for (const [id, asset] of Object.entries(context.mediaAssets ?? {})) if (!Object.hasOwn(imported.assets ?? {}, id)) imported.assets = {...imported.assets, [id]: asset};
|
|
313
323
|
// Report after the slides so slide diagnostics keep their established order.
|
|
@@ -501,6 +511,15 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
501
511
|
if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
|
|
502
512
|
nativeContext.slideImagePictures = slideImage.consumed;
|
|
503
513
|
nativeContext.slideImageShapes = slideImage.consumedShapes;
|
|
514
|
+
const watermarkPath = `slides.${slideIndex}.design.watermark`;
|
|
515
|
+
const watermark = importWatermark(nativeContext.pictures, relationships, entries, slideIndex,
|
|
516
|
+
picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
|
|
517
|
+
// The recorded fit is re-derived from design.watermark.
|
|
518
|
+
if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: watermarkPath});
|
|
519
|
+
}),
|
|
520
|
+
diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
|
|
521
|
+
if (watermark.design) slide.design = {...slide.design, ...watermark.design};
|
|
522
|
+
nativeContext.watermarkPictures = watermark.consumed;
|
|
504
523
|
|
|
505
524
|
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
|
|
506
525
|
.sort(comparePositionedItems);
|
|
@@ -593,7 +612,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
593
612
|
}
|
|
594
613
|
|
|
595
614
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
596
|
-
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index)) continue;
|
|
615
|
+
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index)) continue;
|
|
597
616
|
const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
|
|
598
617
|
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
599
618
|
if (item) items.push(item);
|
|
@@ -1224,7 +1243,9 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1224
1243
|
const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
|
|
1225
1244
|
const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) });
|
|
1226
1245
|
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
1246
|
+
// The preview paints background, slide image (and its overlay), then design.watermark, then content.
|
|
1227
1247
|
if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
|
|
1248
|
+
await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
|
|
1228
1249
|
for (const item of geometry.items) {
|
|
1229
1250
|
const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
|
|
1230
1251
|
if (item.frameBox) {
|
|
@@ -1234,7 +1255,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1234
1255
|
const surface=slideContext.colorScheme[isDarkHex(slideContext.colors.background)?'dark2':'light2']??`#${slideContext.colors.surface}`;
|
|
1235
1256
|
slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
|
|
1236
1257
|
rectRadius:8*Math.min(widthInches,heightInches)/720,
|
|
1237
|
-
fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),
|
|
1258
|
+
fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
|
|
1238
1259
|
}
|
|
1239
1260
|
if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
|
|
1240
1261
|
addMeasuredPayloadText(slide,item.value,item.box,slideContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:alignmentFor(item),diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?slideContext.colors.accent:slideContext.colors.text});
|
|
@@ -1462,6 +1483,33 @@ async function addImagePayload(slide, presentation, asset, region, path, context
|
|
|
1462
1483
|
return objectName;
|
|
1463
1484
|
}
|
|
1464
1485
|
|
|
1486
|
+
// design.watermark: one native picture per slide, added after the slide image
|
|
1487
|
+
// (and its overlay) and before all content, the preview's paint order. The
|
|
1488
|
+
// slide's own design.watermark replaces the deck's, and false suppresses it.
|
|
1489
|
+
// Frame, fit and opacity are written after PptxGenJS embeds the bytes.
|
|
1490
|
+
async function addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options) {
|
|
1491
|
+
const local = opfSlide.design?.watermark !== undefined;
|
|
1492
|
+
const watermark = local ? opfSlide.design.watermark : presentation.design?.watermark;
|
|
1493
|
+
if (watermark === undefined || watermark === null || watermark === false) return;
|
|
1494
|
+
const path = local ? `slides.${slideIndex}.design.watermark` : 'design.watermark';
|
|
1495
|
+
const notExported = message => options.onDiagnostic?.({code: 'watermark-not-exported', path, message});
|
|
1496
|
+
const asset = isPlainObject(watermark) || typeof watermark === 'string' ? watermark : null;
|
|
1497
|
+
if (asset === null || (isPlainObject(asset) && typeof asset.src !== 'string')) {
|
|
1498
|
+
notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const resolved = await resolveImage(asset, presentation, options, path);
|
|
1502
|
+
if (!resolved) {
|
|
1503
|
+
options.onDiagnostic?.({code: 'unresolved-asset', path, message: 'The watermark image needs an embedded raster, a declared asset or a host imageResolver; no watermark was exported for this slide.'});
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
const {widthInches, heightInches} = slideContext.dimensions;
|
|
1507
|
+
const box = watermarkBox(widthInches, heightInches);
|
|
1508
|
+
const opacity = watermarkOpacity(watermark);
|
|
1509
|
+
context.watermarks.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box, opacity, path});
|
|
1510
|
+
slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1465
1513
|
// design.slideImage: one native picture at the shared frame, beneath content.
|
|
1466
1514
|
// Crop/fit and treatments are written after PptxGenJS embeds the bytes.
|
|
1467
1515
|
async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
|
|
@@ -1738,7 +1786,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
|
|
|
1738
1786
|
const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
|
|
1739
1787
|
for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
|
|
1740
1788
|
context.codeTags.set(panelName,codeManifest(value,layout,group));
|
|
1741
|
-
slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155',
|
|
1789
|
+
slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
|
|
1742
1790
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
1743
1791
|
if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
|
|
1744
1792
|
for (const [index,line] of part.fit.sourceLines.entries()) {
|
|
@@ -1801,7 +1849,7 @@ function addTimelinePayload(slide, value, layout, context, options, path) {
|
|
|
1801
1849
|
const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
|
|
1802
1850
|
const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
|
|
1803
1851
|
const {x1,y1,x2,y2}=layout.connector;
|
|
1804
|
-
slide.addShape('line',{objectName:connectorName,x:x1/96,y:y1/96,w:(x2-x1)/96,h:(y2-y1)/96,line:{color:context.colors.border,
|
|
1852
|
+
slide.addShape('line',{objectName:connectorName,x:x1/96,y:y1/96,w:(x2-x1)/96,h:(y2-y1)/96,line:{color:context.colors.border,width:3*scale*.75}});
|
|
1805
1853
|
context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
|
|
1806
1854
|
for(const marker of layout.markers){
|
|
1807
1855
|
const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
|
|
@@ -1829,7 +1877,7 @@ function addMediaPayload(slide, presentation, value, region, path, context, opti
|
|
|
1829
1877
|
context.mediaTags.set(names.badge, {v: 1, role: "badge", path});
|
|
1830
1878
|
context.mediaTags.set(names.play, {v: 1, role: "play", path});
|
|
1831
1879
|
}
|
|
1832
|
-
slide.addShape("rect", {x: region.x, y: region.y, w: region.w, h: region.h, fill: {color: context.colors.surface}, line: {color: context.colors.border,
|
|
1880
|
+
slide.addShape("rect", {x: region.x, y: region.y, w: region.w, h: region.h, fill: {color: context.colors.surface}, line: {color: context.colors.border, width: 0.75}, hyperlink, objectName: names.frame});
|
|
1833
1881
|
slide.addShape("ellipse", {x: icon.x / 96, y: icon.y / 96, w: 72 / 96, h: 72 / 96, fill: {color: context.colors.accent}, line: {transparency: 100}, hyperlink: hyperlink && {url: source}, objectName: names.badge});
|
|
1834
1882
|
slide.addShape("triangle", {x: (icon.x + 28) / 96, y: (icon.y + 22) / 96, w: 28 / 96, h: 28 / 96, rotate: 90, fill: {color: "FFFFFF"}, line: {transparency: 100}, hyperlink: hyperlink && {url: source}, objectName: names.play});
|
|
1835
1883
|
addMeasuredPayloadText(slide, mediaCaption(value), {...box, y: icon.y + 72 + 20, height: 50}, context, options,
|
|
@@ -1914,7 +1962,7 @@ function addPlaceholderPayload(slide, label, description, region, context) {
|
|
|
1914
1962
|
w: region.w,
|
|
1915
1963
|
h: region.h,
|
|
1916
1964
|
fill: { color: context.colors.surface, transparency: 10 },
|
|
1917
|
-
line: { color: context.colors.border,
|
|
1965
|
+
line: { color: context.colors.border, width: 0.75 }
|
|
1918
1966
|
});
|
|
1919
1967
|
slide.addText(`${label}\n${description}`, {
|
|
1920
1968
|
x: region.x + 0.12,
|
|
@@ -1937,12 +1985,11 @@ function addPlaceholderPayload(slide, label, description, region, context) {
|
|
|
1937
1985
|
// always names the developer's chosen family. Measurement still uses the
|
|
1938
1986
|
// substitute, because the provider resolves the chosen family again on every
|
|
1939
1987
|
// measure/outline call, so layout is unchanged.
|
|
1940
|
-
const FACE_STYLE_WORDS = /^(?:(?:thin|hairline|extra ?light|ultra ?light|light|semi ?light|demi ?light|book|regular|normal|medium|semi ?bold|demi ?bold|bold|extra ?bold|ultra ?bold|black|heavy|extra ?black|ultra ?black|italic|oblique)\s*)+$/i;
|
|
1941
1988
|
function sameTypeface(requested, resolved) {
|
|
1942
1989
|
if (typeof resolved !== 'string') return false;
|
|
1943
1990
|
const want = requested.trim().toLowerCase(), got = resolved.trim().toLowerCase();
|
|
1944
1991
|
// A legacy four-style family such as "Roboto Medium" is the chosen typeface.
|
|
1945
|
-
return got === want || got.startsWith(`${want} `) &&
|
|
1992
|
+
return got === want || got.startsWith(`${want} `) && isFaceStyleSuffix(got.slice(want.length + 1));
|
|
1946
1993
|
}
|
|
1947
1994
|
function chosenFamilyMeasurement(measurement) {
|
|
1948
1995
|
if (!measurement || typeof measurement.resolveStyle !== 'function') return measurement;
|
|
@@ -2368,7 +2415,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
2368
2415
|
const relationships = parseRelationships(entries, part);
|
|
2369
2416
|
for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
|
|
2370
2417
|
const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
2371
|
-
const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name);
|
|
2418
|
+
const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : undefined);
|
|
2372
2419
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
2373
2420
|
if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
|
|
2374
2421
|
}
|
|
@@ -2398,6 +2445,11 @@ async function normalizePptxZip(raw, context) {
|
|
|
2398
2445
|
placeSlideImages(entries, context.slideImages, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
2399
2446
|
throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
|
|
2400
2447
|
});
|
|
2448
|
+
placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
2449
|
+
throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
|
|
2450
|
+
});
|
|
2451
|
+
// A picture repeated across slides embeds once (after fitting, which needs each slide's own relationship).
|
|
2452
|
+
dedupeMedia(entries, imageMetadata, resolveRelationshipTarget);
|
|
2401
2453
|
// Charts and notes follow the language and fonts of the slide they belong to.
|
|
2402
2454
|
context.partSlides = new Map();
|
|
2403
2455
|
for (const part of Object.keys(entries)) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// PptxGenJS embeds one media part per picture, so a picture repeated on many
|
|
2
|
+
// slides repeats its bytes. Identical media parts collapse into the first one
|
|
3
|
+
// (in path order); every relationship that pointed at a removed part is
|
|
4
|
+
// re-targeted, so each slide still owns its own relationship and picture.
|
|
5
|
+
const hashBytes = bytes => {
|
|
6
|
+
// Bucket key only (cyrb53); equal buckets are confirmed byte for byte.
|
|
7
|
+
let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
|
|
8
|
+
for (let index = 0; index < bytes.byteLength; index += 1) {
|
|
9
|
+
h1 = Math.imul(h1 ^ bytes[index], 2654435761);
|
|
10
|
+
h2 = Math.imul(h2 ^ bytes[index], 1597334677);
|
|
11
|
+
}
|
|
12
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
13
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
14
|
+
return (h2 >>> 0).toString(16) + (h1 >>> 0).toString(16);
|
|
15
|
+
};
|
|
16
|
+
const sameBytes = (a, b) => a.byteLength === b.byteLength && a.every((value, index) => value === b[index]);
|
|
17
|
+
const decoder = new TextDecoder(), encoder = new TextEncoder();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Remove duplicate ppt/media parts from entries (and their metadata), and
|
|
21
|
+
* re-target relationships. resolve(sourcePart, target) gives a package path.
|
|
22
|
+
* Returns the Map of removed path to the part that replaced it.
|
|
23
|
+
*/
|
|
24
|
+
export function dedupeMedia(entries, metadata, resolve) {
|
|
25
|
+
const canonical = new Map(), buckets = new Map();
|
|
26
|
+
for (const path of Object.keys(entries).filter(path => /^ppt\/media\/[^/]+$/.test(path)).sort()) {
|
|
27
|
+
const bytes = entries[path], key = `${bytes.byteLength}:${hashBytes(bytes)}`;
|
|
28
|
+
const match = (buckets.get(key) ?? []).find(candidate => sameBytes(entries[candidate], bytes));
|
|
29
|
+
if (match) canonical.set(path, match);
|
|
30
|
+
else buckets.set(key, [...(buckets.get(key) ?? []), path]);
|
|
31
|
+
}
|
|
32
|
+
if (!canonical.size) return canonical;
|
|
33
|
+
for (const part of Object.keys(entries).filter(path => path.endsWith('.rels'))) {
|
|
34
|
+
const source = part.replace(/(^|\/)_rels\/([^/]*)\.rels$/, '$1$2');
|
|
35
|
+
const xml = decoder.decode(entries[part]);
|
|
36
|
+
const next = xml.replace(/\bTarget="([^"]+)"/g, (attribute, target) => {
|
|
37
|
+
const replacement = canonical.get(resolve(source, target));
|
|
38
|
+
return replacement ? `Target="${target.slice(0, target.lastIndexOf('/') + 1)}${replacement.slice(replacement.lastIndexOf('/') + 1)}"` : attribute;
|
|
39
|
+
});
|
|
40
|
+
if (next !== xml) entries[part] = encoder.encode(next);
|
|
41
|
+
}
|
|
42
|
+
for (const path of canonical.keys()) { delete entries[path]; metadata?.delete(path); }
|
|
43
|
+
return canonical;
|
|
44
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import {readBackgroundColor} from './background.js';
|
|
2
|
+
import {splitWeightFace} from './font-weights.js';
|
|
2
3
|
|
|
3
4
|
const boolean = value => ['1','true','on'].includes(value) ? true : ['0','false','off'].includes(value) ? false : undefined;
|
|
4
5
|
|
|
6
|
+
const themeFamilies = context => ['a:majorFont', 'a:minorFont'].map(key => context.fonts?.[key]?.['a:latin']?.typeface?.trim().toLowerCase()).filter(Boolean);
|
|
7
|
+
|
|
5
8
|
export function nativeRunStyle(properties, context, relationships, report, kind = 'table') {
|
|
6
9
|
const result = {};
|
|
7
10
|
for (const [native, opf] of [['b','bold'], ['i','italic']]) {
|
|
@@ -23,8 +26,19 @@ export function nativeRunStyle(properties, context, relationships, report, kind
|
|
|
23
26
|
if (font) {
|
|
24
27
|
const match = font.match(/^\+(mj|mn)-(lt|ea|cs)$/);
|
|
25
28
|
const family = match ? context.fonts?.[match[1] === 'mj' ? 'a:majorFont' : 'a:minorFont']?.[{lt:'a:latin',ea:'a:ea',cs:'a:cs'}[match[2]]]?.typeface : font;
|
|
26
|
-
if (family)
|
|
27
|
-
|
|
29
|
+
if (family) {
|
|
30
|
+
result.fontFamily = family;
|
|
31
|
+
// The exporter writes a bundled family's weight faces (Roboto SemiBold) under their
|
|
32
|
+
// native style-link names. OPF runs carry the family plus bold, never the face name.
|
|
33
|
+
// A typeface that is itself a theme font was authored as a family; leave it as is.
|
|
34
|
+
const face = themeFamilies(context).includes(family.trim().toLowerCase()) ? undefined : splitWeightFace(family);
|
|
35
|
+
if (face) {
|
|
36
|
+
result.fontFamily = face.family;
|
|
37
|
+
if (face.weight >= 600) result.bold = true;
|
|
38
|
+
if (face.italic) result.italic = true;
|
|
39
|
+
if (face.weight !== 400 && face.weight !== 700) report(`approximate-${kind}-font-weight`, 'OPF text runs represent regular or bold only; the native weight face was imported as its family, with bold for weights of 600 and above.');
|
|
40
|
+
}
|
|
41
|
+
} else report(`unsupported-${kind}-font`, 'The theme font reference could not be resolved from this archive.');
|
|
28
42
|
}
|
|
29
43
|
if (properties['a:solidFill']) {
|
|
30
44
|
const color = readBackgroundColor(properties['a:solidFill'], context);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {decodeTextTag, encodeTextTag} from './code-provenance.js';
|
|
3
|
+
import {pictureTransform} from './image-geometry.js';
|
|
4
|
+
|
|
5
|
+
// design.watermark exports as one native picture per slide, placed first in the
|
|
6
|
+
// shape tree (beneath content) at the preview's frame: the centered 40% of the
|
|
7
|
+
// slide, fitted without cropping. Opacity is a native a:alphaModFix on the
|
|
8
|
+
// picture's blip. The tag records the exact native picture, so an unchanged
|
|
9
|
+
// picture imports back as design.watermark while an edited one stays ordinary.
|
|
10
|
+
const TAG = 'OPF_WATERMARK_V1', REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
11
|
+
const EMUS_PER_INCH = 914400;
|
|
12
|
+
export const WATERMARK_DEFAULT_OPACITY = 0.08;
|
|
13
|
+
const parser = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', parseTagValue:false, trimValues:false});
|
|
14
|
+
const decoder = new TextDecoder('utf-8', {fatal:true}), encoder = new TextEncoder();
|
|
15
|
+
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
16
|
+
const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
17
|
+
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
|
|
18
|
+
|
|
19
|
+
export const watermarkName = () => 'OPF watermark';
|
|
20
|
+
|
|
21
|
+
// The preview's frame: x/y at 30% and a 40% by 40% box, in the slide's units.
|
|
22
|
+
export const watermarkBox = (width, height) => ({x: width * .3, y: height * .3, w: width * .4, h: height * .4});
|
|
23
|
+
|
|
24
|
+
// The preview reads opacity from an object form only; a string form is 0.08.
|
|
25
|
+
export function watermarkOpacity(value) {
|
|
26
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return WATERMARK_DEFAULT_OPACITY;
|
|
27
|
+
const opacity = value.opacity ?? WATERMARK_DEFAULT_OPACITY;
|
|
28
|
+
return typeof opacity === 'number' && Number.isFinite(opacity) ? Math.min(1, Math.max(0, opacity)) : WATERMARK_DEFAULT_OPACITY;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function blipFillIdentity(blipFill) {
|
|
32
|
+
const {['a:blip']: blip, ...rest} = blipFill ?? {};
|
|
33
|
+
const {['r:embed']: _embed, ...blipRest} = blip ?? {};
|
|
34
|
+
return {...rest, 'a:blip': blipRest};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Place each generated watermark picture at its frame, apply opacity, and bind
|
|
39
|
+
* the picture to its manifest with a native tag.
|
|
40
|
+
* marks: Map<slidePart, {slide, box (inches), opacity, path}>.
|
|
41
|
+
*/
|
|
42
|
+
export function placeWatermarks(entries, marks, metadataFor, fail) {
|
|
43
|
+
if (!marks.size) return;
|
|
44
|
+
const manifests = new Map();
|
|
45
|
+
for (const part of Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
|
|
46
|
+
const mark = marks.get(part);
|
|
47
|
+
if (!mark) continue;
|
|
48
|
+
const xml = decoder.decode(entries[part]);
|
|
49
|
+
const emu = value => Math.round(value * EMUS_PER_INCH);
|
|
50
|
+
let found = 0;
|
|
51
|
+
const next = xml.replace(/<p:pic>[\s\S]*?<\/p:pic>/g, picture => {
|
|
52
|
+
if (picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] !== watermarkName()) return picture;
|
|
53
|
+
found++;
|
|
54
|
+
const embed = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
55
|
+
const metadata = metadataFor(part, embed);
|
|
56
|
+
if (!metadata) fail(mark.path);
|
|
57
|
+
const placed = pictureTransform(metadata, mark.box, 'fit');
|
|
58
|
+
const attributes = `${placed.rotation ? ` rot="${placed.rotation * 60000}"` : ''}${placed.flipH ? ' flipH="1"' : ''}${placed.flipV ? ' flipV="1"' : ''}`;
|
|
59
|
+
picture = picture.replace(/<a:xfrm\b[^>]*>[\s\S]*?<\/a:xfrm>/, `<a:xfrm${attributes}><a:off x="${emu(placed.x)}" y="${emu(placed.y)}"/><a:ext cx="${emu(placed.w)}" cy="${emu(placed.h)}"/></a:xfrm>`);
|
|
60
|
+
picture = picture.replace(/<a:srcRect\b[^>]*\/>/, '');
|
|
61
|
+
if (mark.opacity < 1) {
|
|
62
|
+
const effect = `<a:alphaModFix amt="${Math.round(mark.opacity * 100000)}"/>`;
|
|
63
|
+
picture = picture.replace(/<a:blip\b([^>]*?)(?:\/>|>([\s\S]*?)<\/a:blip>)/, (_, blipAttributes, inner = '') => `<a:blip${blipAttributes}>${effect}${inner}</a:blip>`);
|
|
64
|
+
}
|
|
65
|
+
const node = parser.parse(picture)['p:pic'];
|
|
66
|
+
manifests.set(part, {v: 1, slide: mark.slide, opacity: mark.opacity, properties: node['p:spPr'], blipFill: blipFillIdentity(node['p:blipFill'])});
|
|
67
|
+
return picture;
|
|
68
|
+
});
|
|
69
|
+
if (found !== 1) throw new Error('Missing generated watermark picture.');
|
|
70
|
+
entries[part] = encoder.encode(next);
|
|
71
|
+
}
|
|
72
|
+
attachTags(entries, manifests);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// One tag part per slide, related from the picture's application properties.
|
|
76
|
+
// (attachTextTags is keyed by shape name, and every slide's watermark shares one.)
|
|
77
|
+
function attachTags(entries, manifests) {
|
|
78
|
+
const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main', types = [];
|
|
79
|
+
let count = 0;
|
|
80
|
+
for (const [part, manifest] of manifests) {
|
|
81
|
+
const relPath = part.replace('/slides/', '/slides/_rels/') + '.rels';
|
|
82
|
+
const rels = decoder.decode(entries[relPath]);
|
|
83
|
+
const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match => match[1]));
|
|
84
|
+
const tagPart = `ppt/tags/opfWatermark${++count}.xml`;
|
|
85
|
+
let id = `rIdOpfWatermark${count}`;
|
|
86
|
+
while (ids.has(id)) id += '_';
|
|
87
|
+
entries[tagPart] = encoder.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${TAG}" val="${encodeTextTag(manifest)}"/></p:tagLst>`);
|
|
88
|
+
types.push(`<Override PartName="/${tagPart}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
|
|
89
|
+
entries[relPath] = encoder.encode(rels.replace('</Relationships>', `<Relationship Id="${id}" Type="${REL}" Target="../tags/opfWatermark${count}.xml"/></Relationships>`));
|
|
90
|
+
let attached = false;
|
|
91
|
+
const xml = decoder.decode(entries[part]).replace(/<p:pic>[\s\S]*?<\/p:pic>/g, picture => {
|
|
92
|
+
if (attached || picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] !== watermarkName()) return picture;
|
|
93
|
+
attached = true;
|
|
94
|
+
picture = picture.replace(/<p:nvPr\s*\/>/, '<p:nvPr></p:nvPr>');
|
|
95
|
+
if (!picture.includes('</p:nvPr>')) throw new Error('Generated watermark picture has no native application properties.');
|
|
96
|
+
return picture.replace('</p:nvPr>', `<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst></p:nvPr>`);
|
|
97
|
+
});
|
|
98
|
+
entries[part] = encoder.encode(xml);
|
|
99
|
+
}
|
|
100
|
+
entries['[Content_Types].xml'] = encoder.encode(decoder.decode(entries['[Content_Types].xml']).replace('</Types>', types.join('') + '</Types>'));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readTags(container, relationships, entries) {
|
|
104
|
+
const tags = [];
|
|
105
|
+
let unreadable = false;
|
|
106
|
+
for (const link of array(container?.['p:tags'])) {
|
|
107
|
+
const rel = relationships.get(link['r:id']);
|
|
108
|
+
if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) { unreadable = true; continue; }
|
|
109
|
+
try { tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag'])); } catch { unreadable = true; }
|
|
110
|
+
}
|
|
111
|
+
return {tags, unreadable};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Recover design.watermark from an unchanged tagged picture. Returns the
|
|
116
|
+
* consumed picture indexes and the slide design fields to restore.
|
|
117
|
+
* readPicture(picture) returns the ordinary imported image payload.
|
|
118
|
+
*/
|
|
119
|
+
export function importWatermark(pictures, relationships, entries, slideIndex, readPicture, report) {
|
|
120
|
+
const consumed = new Set(), found = [];
|
|
121
|
+
for (const [index, picture] of pictures.entries()) {
|
|
122
|
+
const {tags, unreadable} = readTags(picture['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries);
|
|
123
|
+
const own = tags.filter(tag => tag.name?.toUpperCase() === TAG);
|
|
124
|
+
if (!own.length) continue;
|
|
125
|
+
found.push({index, picture, own, unreadable, others: tags.some(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== TAG)});
|
|
126
|
+
}
|
|
127
|
+
if (!found.length) return {consumed};
|
|
128
|
+
const invalid = () => report({code: 'invalid-watermark-provenance', message: 'An edited, ambiguous or invalid tagged OPF watermark was imported as an ordinary picture. It is not reconstructed as design.watermark.'});
|
|
129
|
+
if (found.length > 1) { invalid(); return {consumed}; }
|
|
130
|
+
const [{index, picture, own, unreadable, others}] = found;
|
|
131
|
+
try {
|
|
132
|
+
if (unreadable || others || own.length !== 1) throw Error('Ambiguous watermark identity.');
|
|
133
|
+
const manifest = decodeTextTag(own[0].val);
|
|
134
|
+
if (manifest?.v !== 1 || manifest.slide !== `slides.${slideIndex}` || typeof manifest.opacity !== 'number' || !(manifest.opacity >= 0 && manifest.opacity <= 1)) throw Error('Invalid watermark manifest.');
|
|
135
|
+
if (picture['p:nvPicPr']?.['p:cNvPr']?.name !== watermarkName()) throw Error('Watermark identity changed.');
|
|
136
|
+
if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(manifest.blipFill)) throw Error('Watermark geometry or opacity changed.');
|
|
137
|
+
const item = readPicture(picture);
|
|
138
|
+
const src = item?.payload?.image?.src;
|
|
139
|
+
if (typeof src !== 'string') throw Error('Watermark bytes are unavailable.');
|
|
140
|
+
consumed.add(index);
|
|
141
|
+
return {consumed, design: {watermark: {src, opacity: manifest.opacity}}};
|
|
142
|
+
} catch {
|
|
143
|
+
invalid();
|
|
144
|
+
return {consumed};
|
|
145
|
+
}
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"sideEffects": false,
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "node scripts/build.mjs",
|
|
40
|
-
"typecheck": "node --check src/index.js && node --check test/design-alignment.mjs && node --check src/color-ref.js && node --check src/theme-colors.js && node --check test/theme-colors.mjs && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check src/slide-image-provenance.js && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/single-column-chart.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check src/document-provenance.js && node --check test/document-provenance.mjs && node --check test/furniture-provenance.mjs && node --check src/furniture-fields.js && node --check test/furniture-fields.mjs && node --check test/furniture-wrapped-date.mjs && node --check test/native-picture.mjs && node --check test/compare-native-picture.mjs && node --check test/compare-native-picture-edit.mjs && node --check test/compare-native-picture-edit-controls.mjs && node --check test/native-font-embed-audit.mjs && node --check test/native-font-embed-controls.mjs && node --check test/native-font-embed-fixture-source.mjs && node --check test/native-font-edit-fixture.mjs && node --check test/native-mixed-edit-audit.mjs && node --check test/native-mixed-edit-controls.mjs && node --check test/master-bullet-font.mjs && node --check test/native-font-inventory-audit.mjs && node --check test/native-font-inventory-controls.mjs && node --check test/default-font-scheme.mjs && node --check test/export-chosen-fonts.mjs && node --check test/background-fills.mjs && node --check src/typeface-inventory.js && node --check src/package-fonts.js && node --check test/typeface-inventory.mjs && node --check src/script-fonts.js && node --check test/script-fonts.mjs && node --check test/powershell-scan.mjs && node --check test/powershell-scan-probes.mjs && node --check test/socials-furniture.mjs && node --check src/media-provenance.js && node --check test/layout-parity.mjs && node --check test/media-placeholder.mjs && node --check test/image-placeholder.mjs && node --check test/list-shape-names.mjs && node --check test/layout-intent.mjs && node --check test/chart-cache-import.mjs && node --check test/zip-date.mjs && node --check test/native-text-whitespace.mjs && node --check test/native-underline.mjs && node --check src/native-text-style.js && node --check src/body-text-import.js && node --check test/native-body-rich-text.mjs && node --check test/native-body-rich-text-browser.js && node --check test/native-quote-import-contract.mjs && node --check test/native-quote-import-controls.mjs && node --check test/native-quote.mjs && node --check test/determinism.mjs && node --check test/determinism-fixtures.mjs && node --check test/table-frame-furniture-order.mjs",
|
|
41
|
-
"test": "npm run build && node test/zip-date.mjs && node test/background-fills.mjs && npm run test:furniture && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/document-provenance.mjs && node test/layout-intent.mjs && node test/master-bullet-font.mjs && node test/default-font-scheme.mjs && node test/script-fonts.mjs && node test/export-chosen-fonts.mjs && node test/typeface-inventory.mjs && node test/color-ref-export.mjs && node test/theme-colors.mjs && node test/plain-whitespace.mjs && node test/native-text-whitespace.mjs && node test/native-body-rich-text.mjs && node test/native-underline.mjs && node test/design-alignment.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/native-quote-import-controls.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/single-column-chart.mjs && node --test test/chart-cache-import.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/layout-parity.mjs && node test/table-frame-furniture-order.mjs && node test/media-placeholder.mjs && node test/image-placeholder.mjs && node test/list-shape-names.mjs && node test/shared-quote.mjs && npm run test:timeline && npm run test:determinism",
|
|
40
|
+
"typecheck": "node --check src/index.js && node --check test/design-alignment.mjs && node --check src/color-ref.js && node --check src/theme-colors.js && node --check test/theme-colors.mjs && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check src/slide-image-provenance.js && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check src/watermark-provenance.js && node --check src/media-dedupe.js && node --check test/media-dedupe.mjs && node --check test/watermark.mjs && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/single-column-chart.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check src/document-provenance.js && node --check test/document-provenance.mjs && node --check test/furniture-provenance.mjs && node --check src/furniture-fields.js && node --check test/furniture-fields.mjs && node --check test/furniture-wrapped-date.mjs && node --check test/native-picture.mjs && node --check test/compare-native-picture.mjs && node --check test/compare-native-picture-edit.mjs && node --check test/compare-native-picture-edit-controls.mjs && node --check test/native-font-embed-audit.mjs && node --check test/native-font-embed-controls.mjs && node --check test/native-font-embed-fixture-source.mjs && node --check test/native-font-edit-fixture.mjs && node --check test/native-mixed-edit-audit.mjs && node --check test/native-mixed-edit-controls.mjs && node --check test/master-bullet-font.mjs && node --check test/native-font-inventory-audit.mjs && node --check test/native-font-inventory-controls.mjs && node --check test/default-font-scheme.mjs && node --check test/export-chosen-fonts.mjs && node --check test/background-fills.mjs && node --check src/typeface-inventory.js && node --check src/package-fonts.js && node --check test/typeface-inventory.mjs && node --check src/script-fonts.js && node --check test/script-fonts.mjs && node --check test/powershell-scan.mjs && node --check test/powershell-scan-probes.mjs && node --check test/socials-furniture.mjs && node --check src/media-provenance.js && node --check test/layout-parity.mjs && node --check test/cover-centering.mjs && node --check test/media-placeholder.mjs && node --check test/image-placeholder.mjs && node --check test/list-shape-names.mjs && node --check test/layout-intent.mjs && node --check test/chart-cache-import.mjs && node --check test/zip-date.mjs && node --check test/native-text-whitespace.mjs && node --check test/native-underline.mjs && node --check src/native-text-style.js && node --check src/font-weights.js && node --check test/font-weight-import.mjs && node --check src/body-text-import.js && node --check test/native-body-rich-text.mjs && node --check test/native-body-rich-text-browser.js && node --check test/native-quote-import-contract.mjs && node --check test/native-quote-import-controls.mjs && node --check test/native-quote.mjs && node --check test/determinism.mjs && node --check test/determinism-fixtures.mjs && node --check test/table-frame-furniture-order.mjs && node --check test/shape-border-widths.mjs",
|
|
41
|
+
"test": "npm run build && node test/zip-date.mjs && node test/background-fills.mjs && npm run test:furniture && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/watermark.mjs && node test/media-dedupe.mjs && node test/document-provenance.mjs && node test/layout-intent.mjs && node test/master-bullet-font.mjs && node test/default-font-scheme.mjs && node test/script-fonts.mjs && node test/export-chosen-fonts.mjs && node test/typeface-inventory.mjs && node test/color-ref-export.mjs && node test/theme-colors.mjs && node test/plain-whitespace.mjs && node test/native-text-whitespace.mjs && node test/native-body-rich-text.mjs && node test/native-underline.mjs && node test/font-weight-import.mjs && node test/design-alignment.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/native-quote-import-controls.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/single-column-chart.mjs && node --test test/chart-cache-import.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/layout-parity.mjs && node test/cover-centering.mjs && node test/table-frame-furniture-order.mjs && node test/media-placeholder.mjs && node test/shape-border-widths.mjs && node test/image-placeholder.mjs && node test/list-shape-names.mjs && node test/shared-quote.mjs && npm run test:timeline && npm run test:determinism",
|
|
42
42
|
"validate": "node scripts/validate-package.mjs",
|
|
43
43
|
"test:packed": "node test/packed-install.mjs",
|
|
44
44
|
"test:compare-published": "node test/compare-published.mjs",
|
|
@@ -60,14 +60,14 @@
|
|
|
60
60
|
"test:determinism": "node test/determinism.mjs"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@openpresentation/opf": "^0.11.
|
|
63
|
+
"@openpresentation/opf": "^0.11.2",
|
|
64
64
|
"fast-xml-parser": "^5.8.0",
|
|
65
65
|
"fflate": "^0.8.3",
|
|
66
66
|
"jszip": "3.10.2",
|
|
67
67
|
"sharp": "0.35.4"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@openpresentation/opf-render": "^0.
|
|
70
|
+
"@openpresentation/opf-render": "^0.11.0"
|
|
71
71
|
},
|
|
72
72
|
"peerDependenciesMeta": {
|
|
73
73
|
"@openpresentation/opf-render": {
|
|
@@ -75,7 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@
|
|
78
|
+
"@expo-google-fonts/carlito": "0.4.1",
|
|
79
|
+
"@openpresentation/opf-render": "0.11.0",
|
|
79
80
|
"esbuild": "0.28.2",
|
|
80
81
|
"playwright": "1.63.0"
|
|
81
82
|
},
|