mixdog 0.9.154 → 0.9.155
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/package.json +2 -2
- package/scripts/build-voice-runtime-unix.sh +24 -0
- package/scripts/voice-runtime-config.json +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +47 -4
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +4 -1
- package/src/runtime/channels/data/voice-runtime-manifest.json +17 -17
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +59 -0
- package/src/runtime/office/assurance-benchmark.mjs +67 -1
- package/src/runtime/office/assurance.mjs +24 -17
- package/src/runtime/office/capabilities.mjs +17 -17
- package/src/runtime/office/composition-system.mjs +451 -0
- package/src/runtime/office/design-library.mjs +102 -0
- package/src/runtime/office/design-system.mjs +357 -168
- package/src/runtime/office/index.mjs +149 -20
- package/src/runtime/office/office-com-host.ps1 +132 -46
- package/src/runtime/office/pdf-render.mjs +203 -35
- package/src/runtime/office/portable-ooxml.mjs +1090 -47
- package/src/runtime/office/portable-package.mjs +390 -0
- package/src/runtime/office/portable-sheet-styles.mjs +241 -0
- package/src/runtime/office/portable-slide-shapes.mjs +283 -0
- package/src/runtime/office/quality-live-benchmark.mjs +82 -36
- package/src/runtime/office/quality-pipeline.mjs +3 -0
- package/src/runtime/office/tool-defs.mjs +2 -2
- package/src/session-runtime/goal-reminder.mjs +79 -0
- package/src/session-runtime/goal-runtime.mjs +105 -45
- package/src/session-runtime/goal-text.mjs +58 -0
- package/src/session-runtime/runtime-core.mjs +14 -1
- package/src/session-runtime/session-lifecycle.mjs +1 -1
- package/src/session-runtime/session-turn-api.mjs +5 -0
- package/src/standalone/session-service.mjs +30 -3
- package/src/tui/dist/index.mjs +12 -3
- package/src/tui/lib/voice-setup.mjs +19 -3
- package/src/tui/session/goal-continuation.mjs +7 -0
|
@@ -4,6 +4,18 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import JSZip from 'jszip';
|
|
7
|
+
import { applyCellStyle } from './portable-sheet-styles.mjs';
|
|
8
|
+
import {
|
|
9
|
+
backgroundXml,
|
|
10
|
+
pictureXml,
|
|
11
|
+
resolveGeometry,
|
|
12
|
+
shapeXml,
|
|
13
|
+
solidFillXml,
|
|
14
|
+
supportedShapeTypes,
|
|
15
|
+
tableXml,
|
|
16
|
+
textBodyXml,
|
|
17
|
+
toEmu,
|
|
18
|
+
} from './portable-slide-shapes.mjs';
|
|
7
19
|
|
|
8
20
|
const OOXML_REQUIRED = {
|
|
9
21
|
docx: ['[Content_Types].xml', 'word/document.xml'],
|
|
@@ -11,6 +23,9 @@ const OOXML_REQUIRED = {
|
|
|
11
23
|
pptx: ['[Content_Types].xml', 'ppt/presentation.xml'],
|
|
12
24
|
};
|
|
13
25
|
|
|
26
|
+
const SPREADSHEET_MAIN = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
|
|
27
|
+
const OFFICE_RELATIONSHIP_BASE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
28
|
+
|
|
14
29
|
function xmlDecode(value = '') {
|
|
15
30
|
return String(value)
|
|
16
31
|
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
|
@@ -263,6 +278,15 @@ function docxBodyModel(documentXml) {
|
|
|
263
278
|
return { paragraphs, tables, blocks, body };
|
|
264
279
|
}
|
|
265
280
|
|
|
281
|
+
function appendDocxBlock(documentXml, block) {
|
|
282
|
+
const model = docxBodyModel(documentXml);
|
|
283
|
+
if (!model.body) throw new Error('DOCX document body is missing');
|
|
284
|
+
const trailingSection = /<w:sectPr(?:\s[^>]*)?(?:\/>|>[\s\S]*?<\/w:sectPr>)\s*$/.exec(model.body.inner);
|
|
285
|
+
const position = trailingSection ? trailingSection.index : model.body.inner.length;
|
|
286
|
+
const inner = `${model.body.inner.slice(0, position)}${block}${model.body.inner.slice(position)}`;
|
|
287
|
+
return `${documentXml.slice(0, model.body.start)}${inner}${documentXml.slice(model.body.end)}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
266
290
|
async function snapshotDocx(zip, options = {}) {
|
|
267
291
|
const parts = Object.keys(zip.files)
|
|
268
292
|
.filter((name) => /^word\/(document|header\d+|footer\d+|footnotes|endnotes|comments)\.xml$/i.test(name))
|
|
@@ -772,14 +796,20 @@ function parseCellRef(ref) {
|
|
|
772
796
|
return { col: match[1].toUpperCase(), row: Number(match[2]), ref: `${match[1].toUpperCase()}${match[2]}` };
|
|
773
797
|
}
|
|
774
798
|
|
|
775
|
-
function cellXml(ref, value, formula = '') {
|
|
799
|
+
function cellXml(ref, value, formula = '', style = '') {
|
|
800
|
+
const styled = style === '' ? '' : ` s="${style}"`;
|
|
776
801
|
if (formula) {
|
|
777
802
|
const normalized = String(formula).replace(/^=/, '');
|
|
778
|
-
return `<c r="${ref}"><f>${xmlEncode(normalized)}</f><v></v></c>`;
|
|
803
|
+
return `<c r="${ref}"${styled}><f>${xmlEncode(normalized)}</f><v></v></c>`;
|
|
779
804
|
}
|
|
780
|
-
if (typeof value === 'number' && Number.isFinite(value)) return `<c r="${ref}"><v>${value}</v></c>`;
|
|
781
|
-
if (typeof value === 'boolean') return `<c r="${ref}" t="b"><v>${value ? 1 : 0}</v></c>`;
|
|
782
|
-
return `<c r="${ref}" t="inlineStr"><is><t${/^\s|\s$/.test(String(value ?? '')) ? ' xml:space="preserve"' : ''}>${xmlEncode(value ?? '')}</t></is></c>`;
|
|
805
|
+
if (typeof value === 'number' && Number.isFinite(value)) return `<c r="${ref}"${styled}><v>${value}</v></c>`;
|
|
806
|
+
if (typeof value === 'boolean') return `<c r="${ref}"${styled} t="b"><v>${value ? 1 : 0}</v></c>`;
|
|
807
|
+
return `<c r="${ref}"${styled} t="inlineStr"><is><t${/^\s|\s$/.test(String(value ?? '')) ? ' xml:space="preserve"' : ''}>${xmlEncode(value ?? '')}</t></is></c>`;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function existingCellStyle(xml, ref) {
|
|
811
|
+
const match = new RegExp(`<c\\b([^>]*\\br="${ref}"[^>]*?)(?:\\/>|>)`, 'i').exec(xml);
|
|
812
|
+
return match ? (/\bs="(\d+)"/.exec(match[1])?.[1] || '') : '';
|
|
783
813
|
}
|
|
784
814
|
|
|
785
815
|
function setXmlAttribute(attributes, name, value) {
|
|
@@ -802,24 +832,75 @@ function forceWorkbookRecalculation(xml) {
|
|
|
802
832
|
return xml.replace(/<\/workbook>/i, '<calcPr calcMode="auto" fullCalcOnLoad="1" forceFullCalc="1"/></workbook>');
|
|
803
833
|
}
|
|
804
834
|
|
|
835
|
+
function elementSpans(fragment, tag) {
|
|
836
|
+
const spans = [];
|
|
837
|
+
const regex = new RegExp(`<${tagPattern(tag)}\\b([^>]*?)(\\/>|>[\\s\\S]*?<\\/${tagPattern(tag)}>)`, 'g');
|
|
838
|
+
let match;
|
|
839
|
+
while ((match = regex.exec(fragment))) {
|
|
840
|
+
spans.push({
|
|
841
|
+
start: match.index,
|
|
842
|
+
end: regex.lastIndex,
|
|
843
|
+
attrs: match[1],
|
|
844
|
+
xml: match[0],
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
return spans;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function containerBody(xml, tag) {
|
|
851
|
+
if (xml.endsWith('/>')) return '';
|
|
852
|
+
return xml.slice(xml.indexOf('>') + 1, xml.lastIndexOf(`</${tag}>`));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function setRowCell(rowXml, ref, column, cell) {
|
|
856
|
+
const attrs = /^<row\b([^>]*?)(?:\/>|>)/.exec(rowXml)?.[1] || '';
|
|
857
|
+
const body = containerBody(rowXml, 'row');
|
|
858
|
+
const cells = elementSpans(body, 'c')
|
|
859
|
+
.map((span) => ({ ...span, ref: (/\br="([A-Za-z]+\d+)"/.exec(span.attrs)?.[1] || '').toUpperCase() }))
|
|
860
|
+
.filter((span) => span.ref);
|
|
861
|
+
const existing = cells.find((span) => span.ref === ref);
|
|
862
|
+
if (existing) {
|
|
863
|
+
return `<row${attrs}>${body.slice(0, existing.start)}${cell}${body.slice(existing.end)}</row>`;
|
|
864
|
+
}
|
|
865
|
+
const following = cells.find((span) => columnNumber(parseCellRef(span.ref).col) > column);
|
|
866
|
+
const position = following ? following.start : body.length;
|
|
867
|
+
return `<row${attrs}>${body.slice(0, position)}${cell}${body.slice(position)}</row>`;
|
|
868
|
+
}
|
|
869
|
+
|
|
805
870
|
function setCellInSheet(xml, ref, value, formula = '') {
|
|
806
871
|
const parsed = parseCellRef(ref);
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
return xml.
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
872
|
+
return placeCellInSheet(xml, parsed.ref, cellXml(parsed.ref, value, formula, existingCellStyle(xml, parsed.ref)));
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function setCellStyleInSheet(xml, ref, styleIndex) {
|
|
876
|
+
const parsed = parseCellRef(ref);
|
|
877
|
+
const pattern = new RegExp(`<c\\b([^>]*\\br="${parsed.ref}"[^>]*?)(\\/>|>[\\s\\S]*?<\\/c>)`, 'i');
|
|
878
|
+
const match = pattern.exec(xml);
|
|
879
|
+
if (match) {
|
|
880
|
+
const attrs = setXmlAttribute(match[1], 's', styleIndex);
|
|
881
|
+
return `${xml.slice(0, match.index)}<c${attrs}${match[2]}${xml.slice(match.index + match[0].length)}`;
|
|
882
|
+
}
|
|
883
|
+
return placeCellInSheet(xml, parsed.ref, `<c r="${parsed.ref}" s="${styleIndex}"/>`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function placeCellInSheet(xml, ref, cell) {
|
|
887
|
+
const parsed = parseCellRef(ref);
|
|
888
|
+
const column = columnNumber(parsed.col);
|
|
889
|
+
const sheetData = /<sheetData(?:\s[^>]*)?(?:\/>|>[\s\S]*?<\/sheetData>)/.exec(xml);
|
|
890
|
+
if (!sheetData) throw new Error('Worksheet is missing sheetData');
|
|
891
|
+
const inner = containerBody(sheetData[0], 'sheetData');
|
|
892
|
+
const rows = elementSpans(inner, 'row')
|
|
893
|
+
.map((span) => ({ ...span, index: Number(/\br="(\d+)"/.exec(span.attrs)?.[1] || 0) }));
|
|
894
|
+
const existing = rows.find((row) => row.index === parsed.row);
|
|
895
|
+
let nextInner;
|
|
896
|
+
if (existing) {
|
|
897
|
+
nextInner = `${inner.slice(0, existing.start)}${setRowCell(existing.xml, parsed.ref, column, cell)}${inner.slice(existing.end)}`;
|
|
898
|
+
} else {
|
|
899
|
+
const following = rows.find((row) => row.index > parsed.row);
|
|
900
|
+
const position = following ? following.start : inner.length;
|
|
901
|
+
nextInner = `${inner.slice(0, position)}<row r="${parsed.row}">${cell}</row>${inner.slice(position)}`;
|
|
902
|
+
}
|
|
903
|
+
return `${xml.slice(0, sheetData.index)}<sheetData>${nextInner}</sheetData>${xml.slice(sheetData.index + sheetData[0].length)}`;
|
|
823
904
|
}
|
|
824
905
|
|
|
825
906
|
function columnNumber(label) {
|
|
@@ -945,14 +1026,13 @@ async function applyDocx(zip, operations) {
|
|
|
945
1026
|
const font = op.properties || {};
|
|
946
1027
|
const runProperties = [
|
|
947
1028
|
font.name ? `<w:rFonts w:ascii="${xmlEncode(font.name)}" w:hAnsi="${xmlEncode(font.name)}" w:eastAsia="${xmlEncode(font.name)}"/>` : '',
|
|
948
|
-
font.size ? `<w:sz w:val="${Math.max(2, Math.round(Number(font.size) * 2))}"/>` : '',
|
|
949
1029
|
font.bold === true ? '<w:b/>' : '',
|
|
950
1030
|
font.italic === true ? '<w:i/>' : '',
|
|
951
1031
|
font.color ? `<w:color w:val="${xmlEncode(String(font.color).replace(/^#/, ''))}"/>` : '',
|
|
1032
|
+
font.size ? `<w:sz w:val="${Math.max(2, Math.round(Number(font.size) * 2))}"/><w:szCs w:val="${Math.max(2, Math.round(Number(font.size) * 2))}"/>` : '',
|
|
952
1033
|
].join('');
|
|
953
1034
|
const block = `<w:p>${paragraphProperties}<w:r>${runProperties ? `<w:rPr>${runProperties}</w:rPr>` : ''}<w:t${/^\s|\s$/.test(String(op.text || '')) ? ' xml:space="preserve"' : ''}>${xmlEncode(op.text || '')}</w:t></w:r></w:p>`;
|
|
954
|
-
|
|
955
|
-
zip.file('word/document.xml', current.replace('</w:body>', `${block}</w:body>`));
|
|
1035
|
+
zip.file('word/document.xml', appendDocxBlock(current, block));
|
|
956
1036
|
results.push({ op: op.op, changed: true, style: style || '' });
|
|
957
1037
|
continue;
|
|
958
1038
|
}
|
|
@@ -966,10 +1046,8 @@ async function applyDocx(zip, operations) {
|
|
|
966
1046
|
const position = paragraph.end;
|
|
967
1047
|
const nextInner = `${model.body.inner.slice(0, position)}${table}${model.body.inner.slice(position)}`;
|
|
968
1048
|
current = `${current.slice(0, model.body.start)}${nextInner}${current.slice(model.body.end)}`;
|
|
969
|
-
} else if (/<w:sectPr(?:\s[^>]*)?>/.test(current)) {
|
|
970
|
-
current = current.replace(/<w:sectPr(?:\s[^>]*)?>/, `${table}$&`);
|
|
971
1049
|
} else {
|
|
972
|
-
current = current
|
|
1050
|
+
current = appendDocxBlock(current, table);
|
|
973
1051
|
}
|
|
974
1052
|
zip.file('word/document.xml', current);
|
|
975
1053
|
results.push({ op: op.op, changed: true, table: docxBodyModel(current).blocks.filter((block) => block.name === 'w:tbl').length });
|
|
@@ -1102,7 +1180,12 @@ async function applyDocx(zip, operations) {
|
|
|
1102
1180
|
].join('');
|
|
1103
1181
|
if (runFormat) {
|
|
1104
1182
|
nextCell = /<w:rPr(?:\s[^>]*)?>/.test(nextCell)
|
|
1105
|
-
? nextCell.replace(/<w:rPr(?:\s[^>]*)
|
|
1183
|
+
? nextCell.replace(/<w:rPr(?:\s[^>]*)?>([\s\S]*?)<\/w:rPr>/, (_, inner) => {
|
|
1184
|
+
const fonts = /<w:rFonts\b[^>]*\/>/.exec(inner);
|
|
1185
|
+
return fonts
|
|
1186
|
+
? `<w:rPr>${fonts[0]}${runFormat}${inner.replace(fonts[0], '')}</w:rPr>`
|
|
1187
|
+
: `<w:rPr>${runFormat}${inner}</w:rPr>`;
|
|
1188
|
+
})
|
|
1106
1189
|
: nextCell.replace(/<w:r(?:\s[^>]*)?>/, (open) => `${open}<w:rPr>${runFormat}</w:rPr>`);
|
|
1107
1190
|
}
|
|
1108
1191
|
nextTable = table[0].replace(cell[0], nextCell);
|
|
@@ -1150,14 +1233,289 @@ async function applyDocx(zip, operations) {
|
|
|
1150
1233
|
return results;
|
|
1151
1234
|
}
|
|
1152
1235
|
|
|
1236
|
+
const WORKSHEET_SECTIONS = Object.freeze([
|
|
1237
|
+
'sheetPr', 'dimension', 'sheetViews', 'sheetFormatPr', 'cols', 'sheetData',
|
|
1238
|
+
'sheetCalcPr', 'sheetProtection', 'protectedRanges', 'scenarios', 'autoFilter',
|
|
1239
|
+
'sortState', 'dataConsolidate', 'customSheetViews', 'mergeCells', 'phoneticPr',
|
|
1240
|
+
'conditionalFormatting', 'dataValidations', 'hyperlinks', 'printOptions',
|
|
1241
|
+
'pageMargins', 'pageSetup', 'headerFooter', 'rowBreaks', 'colBreaks',
|
|
1242
|
+
'customProperties', 'cellWatches', 'ignoredErrors', 'smartTags', 'drawing',
|
|
1243
|
+
'legacyDrawing', 'legacyDrawingHF', 'picture', 'oleObjects', 'controls',
|
|
1244
|
+
'webPublishItems', 'tableParts', 'extLst',
|
|
1245
|
+
]);
|
|
1246
|
+
const WORKSHEET_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml';
|
|
1247
|
+
const WORKSHEET_RELATIONSHIP = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet';
|
|
1248
|
+
const MAX_STYLED_CELLS = 20_000;
|
|
1249
|
+
|
|
1250
|
+
function worksheetSection(xml, name) {
|
|
1251
|
+
return new RegExp(`<${name}\\b[^>]*?(?:\\/>|>[\\s\\S]*?<\\/${name}>)`).exec(xml);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function upsertWorksheetSection(xml, name, element) {
|
|
1255
|
+
const existing = worksheetSection(xml, name);
|
|
1256
|
+
const base = existing
|
|
1257
|
+
? `${xml.slice(0, existing.index)}${xml.slice(existing.index + existing[0].length)}`
|
|
1258
|
+
: xml;
|
|
1259
|
+
if (!element) return base;
|
|
1260
|
+
const position = WORKSHEET_SECTIONS.indexOf(name);
|
|
1261
|
+
for (const candidate of WORKSHEET_SECTIONS.slice(position + 1)) {
|
|
1262
|
+
const found = worksheetSection(base, candidate);
|
|
1263
|
+
if (found) return `${base.slice(0, found.index)}${element}${base.slice(found.index)}`;
|
|
1264
|
+
}
|
|
1265
|
+
return base.replace(/<\/worksheet>\s*$/, `${element}</worksheet>`);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function mergedRanges(xml) {
|
|
1269
|
+
const section = worksheetSection(xml, 'mergeCells');
|
|
1270
|
+
if (!section) return [];
|
|
1271
|
+
return [...section[0].matchAll(/<mergeCell\b[^>]*\bref="([^"]+)"[^>]*\/>/g)]
|
|
1272
|
+
.map((match) => match[1].toUpperCase());
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function writeMergedRanges(xml, ranges) {
|
|
1276
|
+
const unique = [...new Set(ranges)];
|
|
1277
|
+
const element = unique.length
|
|
1278
|
+
? `<mergeCells count="${unique.length}">${unique.map((ref) => `<mergeCell ref="${ref}"/>`).join('')}</mergeCells>`
|
|
1279
|
+
: '';
|
|
1280
|
+
return upsertWorksheetSection(xml, 'mergeCells', element);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function sheetViewParts(view) {
|
|
1284
|
+
const open = /^<sheetView\b([^>]*?)(\/>|>)/.exec(view);
|
|
1285
|
+
if (!open) throw new Error('Worksheet view is malformed');
|
|
1286
|
+
return {
|
|
1287
|
+
attrs: open[1],
|
|
1288
|
+
body: open[2] === '/>' ? '' : view.slice(open[0].length, view.lastIndexOf('</sheetView>')),
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function composeSheetView(attrs, body) {
|
|
1293
|
+
return body ? `<sheetView${attrs}>${body}</sheetView>` : `<sheetView${attrs}/>`;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function updateSheetView(xml, mutate) {
|
|
1297
|
+
const section = worksheetSection(xml, 'sheetViews');
|
|
1298
|
+
const current = section
|
|
1299
|
+
? /<sheetView\b[^>]*?(?:\/>|>[\s\S]*?<\/sheetView>)/.exec(section[0])?.[0] || ''
|
|
1300
|
+
: '';
|
|
1301
|
+
const next = mutate(current || '<sheetView workbookViewId="0"/>');
|
|
1302
|
+
return upsertWorksheetSection(xml, 'sheetViews', `<sheetViews>${next}</sheetViews>`);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function freezePaneXml(row, column) {
|
|
1306
|
+
const ySplit = Math.max(0, (Number(row) || 0) - 1);
|
|
1307
|
+
const xSplit = Math.max(0, (Number(column) || 0) - 1);
|
|
1308
|
+
if (!ySplit && !xSplit) return '';
|
|
1309
|
+
const topLeft = `${columnLabel(xSplit + 1)}${ySplit + 1}`;
|
|
1310
|
+
const activePane = ySplit && xSplit ? 'bottomRight' : ySplit ? 'bottomLeft' : 'topRight';
|
|
1311
|
+
return `<pane${xSplit ? ` xSplit="${xSplit}"` : ''}${ySplit ? ` ySplit="${ySplit}"` : ''}`
|
|
1312
|
+
+ ` topLeftCell="${topLeft}" activePane="${activePane}" state="frozen"/>`;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function parseAreaRange(range) {
|
|
1316
|
+
const text = String(range || '').trim().toUpperCase();
|
|
1317
|
+
const columns = /^([A-Z]+):([A-Z]+)$/.exec(text);
|
|
1318
|
+
if (columns) {
|
|
1319
|
+
return { startCol: columnNumber(columns[1]), endCol: columnNumber(columns[2]), startRow: 0, endRow: 0 };
|
|
1320
|
+
}
|
|
1321
|
+
const rows = /^(\d+):(\d+)$/.exec(text);
|
|
1322
|
+
if (rows) return { startCol: 0, endCol: 0, startRow: Number(rows[1]), endRow: Number(rows[2]) };
|
|
1323
|
+
const area = /^([A-Z]+\d+):([A-Z]+\d+)$/.exec(text);
|
|
1324
|
+
if (area) {
|
|
1325
|
+
const start = parseCellRef(area[1]);
|
|
1326
|
+
const end = parseCellRef(area[2]);
|
|
1327
|
+
return {
|
|
1328
|
+
startCol: Math.min(columnNumber(start.col), columnNumber(end.col)),
|
|
1329
|
+
endCol: Math.max(columnNumber(start.col), columnNumber(end.col)),
|
|
1330
|
+
startRow: Math.min(start.row, end.row),
|
|
1331
|
+
endRow: Math.max(start.row, end.row),
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
if (/^[A-Z]+\d+$/.test(text)) {
|
|
1335
|
+
const single = parseCellRef(text);
|
|
1336
|
+
const column = columnNumber(single.col);
|
|
1337
|
+
return { startCol: column, endCol: column, startRow: single.row, endRow: single.row };
|
|
1338
|
+
}
|
|
1339
|
+
throw new Error(`Unsupported range: ${range}`);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function displayWidth(text) {
|
|
1343
|
+
let width = 0;
|
|
1344
|
+
for (const character of String(text ?? '')) {
|
|
1345
|
+
width += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(character)
|
|
1346
|
+
? 2
|
|
1347
|
+
: 1;
|
|
1348
|
+
}
|
|
1349
|
+
return width;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function writeColumnWidths(xml, widths) {
|
|
1353
|
+
if (!widths.size) return xml;
|
|
1354
|
+
const entries = new Map();
|
|
1355
|
+
const existing = worksheetSection(xml, 'cols');
|
|
1356
|
+
if (existing) {
|
|
1357
|
+
for (const match of existing[0].matchAll(/<col\b([^>]*?)\/>/g)) {
|
|
1358
|
+
const min = Number(xmlAttribute(match[1], 'min')) || 0;
|
|
1359
|
+
const max = Number(xmlAttribute(match[1], 'max')) || min;
|
|
1360
|
+
for (let column = min; column >= 1 && column <= max && column - min < 2048; column += 1) {
|
|
1361
|
+
entries.set(column, match[1]);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
for (const [column, width] of widths) {
|
|
1366
|
+
entries.set(column, ` min="${column}" max="${column}" width="${width}" customWidth="1"`);
|
|
1367
|
+
}
|
|
1368
|
+
const body = [...entries.entries()]
|
|
1369
|
+
.sort((left, right) => left[0] - right[0])
|
|
1370
|
+
.map(([column, attrs]) => `<col${setXmlAttribute(setXmlAttribute(attrs, 'min', column), 'max', column)}/>`)
|
|
1371
|
+
.join('');
|
|
1372
|
+
return upsertWorksheetSection(xml, 'cols', `<cols>${body}</cols>`);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function quoteSheetName(name) {
|
|
1376
|
+
return /^[A-Za-z_][A-Za-z0-9_.]*$/.test(name) ? name : `'${String(name).replace(/'/g, "''")}'`;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function absoluteRange(range) {
|
|
1380
|
+
return String(range)
|
|
1381
|
+
.split(':')
|
|
1382
|
+
.map((part) => part.replace(/^([A-Za-z]+)(\d+)$/, '$$$1$$$2'))
|
|
1383
|
+
.join(':');
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function upsertDefinedName(xml, entry, matches) {
|
|
1387
|
+
const section = /<definedNames\b[^>]*?(?:\/>|>[\s\S]*?<\/definedNames>)/.exec(xml);
|
|
1388
|
+
const items = section
|
|
1389
|
+
? [...section[0].matchAll(/<definedName\b[^>]*?(?:\/>|>[\s\S]*?<\/definedName>)/g)].map((match) => match[0])
|
|
1390
|
+
: [];
|
|
1391
|
+
const kept = items.filter((item) => !matches(item));
|
|
1392
|
+
if (entry) kept.push(entry);
|
|
1393
|
+
const element = kept.length ? `<definedNames>${kept.join('')}</definedNames>` : '';
|
|
1394
|
+
if (section) {
|
|
1395
|
+
return `${xml.slice(0, section.index)}${element}${xml.slice(section.index + section[0].length)}`;
|
|
1396
|
+
}
|
|
1397
|
+
if (!element) return xml;
|
|
1398
|
+
const calculation = /<calcPr\b[^>]*?(?:\/>|>[\s\S]*?<\/calcPr>)/.exec(xml);
|
|
1399
|
+
if (calculation) return `${xml.slice(0, calculation.index)}${element}${xml.slice(calculation.index)}`;
|
|
1400
|
+
return xml.replace(/<\/workbook>\s*$/, `${element}</workbook>`);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function nextRelationshipId(rels) {
|
|
1404
|
+
const ids = [...rels.matchAll(/\bId="rId(\d+)"/g)].map((match) => Number(match[1]));
|
|
1405
|
+
return `rId${Math.max(0, ...ids) + 1}`;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function emptyWorksheetXml() {
|
|
1409
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<worksheet xmlns="${SPREADSHEET_MAIN}" xmlns:r="${OFFICE_RELATIONSHIP_BASE}">`
|
|
1410
|
+
+ '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'
|
|
1411
|
+
+ '<sheetFormatPr defaultRowHeight="15"/>'
|
|
1412
|
+
+ '<sheetData/></worksheet>';
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
async function addWorksheet(zip, name) {
|
|
1416
|
+
const label = String(name || '').trim();
|
|
1417
|
+
if (!label) throw new Error('add_sheet requires name');
|
|
1418
|
+
if (label.length > 31) throw new Error('Worksheet names are limited to 31 characters');
|
|
1419
|
+
const workbookPath = 'xl/workbook.xml';
|
|
1420
|
+
const workbook = await zipText(zip, workbookPath);
|
|
1421
|
+
if (new RegExp(`<sheet\\b[^>]*\\bname="${tagPattern(xmlEncode(label))}"`, 'i').test(workbook)) {
|
|
1422
|
+
throw new Error(`Worksheet already exists: ${label}`);
|
|
1423
|
+
}
|
|
1424
|
+
let ordinal = 1;
|
|
1425
|
+
while (zip.file(`xl/worksheets/sheet${ordinal}.xml`)) ordinal += 1;
|
|
1426
|
+
const part = `xl/worksheets/sheet${ordinal}.xml`;
|
|
1427
|
+
zip.file(part, emptyWorksheetXml());
|
|
1428
|
+
const relsPath = 'xl/_rels/workbook.xml.rels';
|
|
1429
|
+
const rels = await zipText(zip, relsPath);
|
|
1430
|
+
if (!rels) throw new Error('Workbook relationships are missing');
|
|
1431
|
+
const relationshipId = nextRelationshipId(rels);
|
|
1432
|
+
zip.file(relsPath, rels.replace('</Relationships>', `<Relationship Id="${relationshipId}" Type="${WORKSHEET_RELATIONSHIP}" Target="worksheets/sheet${ordinal}.xml"/></Relationships>`));
|
|
1433
|
+
const sheetIds = [...workbook.matchAll(/<sheet\b[^>]*\bsheetId="(\d+)"/g)].map((match) => Number(match[1]));
|
|
1434
|
+
const sheetId = Math.max(0, ...sheetIds) + 1;
|
|
1435
|
+
const entry = `<sheet name="${xmlEncode(label)}" sheetId="${sheetId}" r:id="${relationshipId}"/>`;
|
|
1436
|
+
const sheetsSection = /<sheets\b[^>]*?(?:\/>|>[\s\S]*?<\/sheets>)/.exec(workbook);
|
|
1437
|
+
if (!sheetsSection) throw new Error('Workbook is missing its sheet list');
|
|
1438
|
+
const next = sheetsSection[0].endsWith('/>')
|
|
1439
|
+
? `<sheets>${entry}</sheets>`
|
|
1440
|
+
: sheetsSection[0].replace('</sheets>', `${entry}</sheets>`);
|
|
1441
|
+
zip.file(workbookPath, `${workbook.slice(0, sheetsSection.index)}${next}${workbook.slice(sheetsSection.index + sheetsSection[0].length)}`);
|
|
1442
|
+
const types = await zipText(zip, '[Content_Types].xml');
|
|
1443
|
+
if (!types.includes(`PartName="/${part}"`)) {
|
|
1444
|
+
zip.file('[Content_Types].xml', types.replace('</Types>', `<Override PartName="/${part}" ContentType="${WORKSHEET_CONTENT_TYPE}"/></Types>`));
|
|
1445
|
+
}
|
|
1446
|
+
return { name: label, path: part, sheetId };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async function renameWorksheet(zip, sheet, name) {
|
|
1450
|
+
const label = String(name || '').trim();
|
|
1451
|
+
if (!label) throw new Error('rename_sheet requires name');
|
|
1452
|
+
if (label.length > 31) throw new Error('Worksheet names are limited to 31 characters');
|
|
1453
|
+
const workbookPath = 'xl/workbook.xml';
|
|
1454
|
+
const workbook = await zipText(zip, workbookPath);
|
|
1455
|
+
const pattern = new RegExp(`<sheet\\b[^>]*\\bname="${tagPattern(xmlEncode(sheet.name))}"[^>]*\\/>`, 'i');
|
|
1456
|
+
const match = pattern.exec(workbook);
|
|
1457
|
+
if (!match) throw new Error(`Worksheet not found: ${sheet.name}`);
|
|
1458
|
+
const replaced = match[0].replace(/\bname="[^"]*"/, `name="${xmlEncode(label)}"`);
|
|
1459
|
+
zip.file(workbookPath, `${workbook.slice(0, match.index)}${replaced}${workbook.slice(match.index + match[0].length)}`);
|
|
1460
|
+
return { from: sheet.name, to: label };
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
async function deleteWorksheet(zip, sheets, sheet) {
|
|
1464
|
+
if (sheets.length <= 1) throw new Error('A workbook must keep at least one worksheet');
|
|
1465
|
+
const index = sheets.findIndex((entry) => entry.name === sheet.name);
|
|
1466
|
+
const workbookPath = 'xl/workbook.xml';
|
|
1467
|
+
let workbook = await zipText(zip, workbookPath);
|
|
1468
|
+
const pattern = new RegExp(`<sheet\\b[^>]*\\bname="${tagPattern(xmlEncode(sheet.name))}"[^>]*\\/>`, 'i');
|
|
1469
|
+
const match = pattern.exec(workbook);
|
|
1470
|
+
if (!match) throw new Error(`Worksheet not found: ${sheet.name}`);
|
|
1471
|
+
workbook = `${workbook.slice(0, match.index)}${workbook.slice(match.index + match[0].length)}`;
|
|
1472
|
+
workbook = upsertDefinedName(workbook, '', (item) => Number(xmlAttribute(item, 'localSheetId')) === index);
|
|
1473
|
+
workbook = workbook.replace(/<definedName\b[^>]*?(?:\/>|>[\s\S]*?<\/definedName>)/g, (item) => {
|
|
1474
|
+
const local = Number(xmlAttribute(item, 'localSheetId'));
|
|
1475
|
+
return Number.isFinite(local) && local > index
|
|
1476
|
+
? item.replace(/\blocalSheetId="\d+"/, `localSheetId="${local - 1}"`)
|
|
1477
|
+
: item;
|
|
1478
|
+
});
|
|
1479
|
+
zip.file(workbookPath, workbook);
|
|
1480
|
+
const relsPath = 'xl/_rels/workbook.xml.rels';
|
|
1481
|
+
const rels = await zipText(zip, relsPath);
|
|
1482
|
+
zip.file(relsPath, rels.replace(new RegExp(`<Relationship\\b[^>]*\\bId="${tagPattern(sheet.rid)}"[^>]*\\/>`), ''));
|
|
1483
|
+
zip.remove(sheet.path);
|
|
1484
|
+
const partRels = `${posix.dirname(sheet.path)}/_rels/${posix.basename(sheet.path)}.rels`;
|
|
1485
|
+
if (zip.file(partRels)) zip.remove(partRels);
|
|
1486
|
+
const types = await zipText(zip, '[Content_Types].xml');
|
|
1487
|
+
zip.file('[Content_Types].xml', types.replace(new RegExp(`<Override\\b[^>]*\\bPartName="/${tagPattern(sheet.path)}"[^>]*\\/>`), ''));
|
|
1488
|
+
return { sheet: sheet.name };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1153
1491
|
async function applyXlsx(zip, operations) {
|
|
1154
|
-
|
|
1155
|
-
const byName = new Map(sheets.map((sheet) => [sheet.name.toLowerCase(), sheet]));
|
|
1492
|
+
let sheets = await workbookSheets(zip);
|
|
1156
1493
|
const results = [];
|
|
1157
1494
|
let recalculationRequired = false;
|
|
1158
1495
|
for (const op of operations) {
|
|
1159
|
-
|
|
1160
|
-
|
|
1496
|
+
if (op.op === 'add_sheet') {
|
|
1497
|
+
const created = await addWorksheet(zip, op.name);
|
|
1498
|
+
sheets = await workbookSheets(zip);
|
|
1499
|
+
results.push({ op: op.op, changed: true, sheet: created.name });
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
const selected = op.sheet
|
|
1503
|
+
? sheets.find((entry) => entry.name.toLowerCase() === String(op.sheet).toLowerCase())
|
|
1504
|
+
: sheets[0];
|
|
1505
|
+
if (!selected) throw new Error(`Worksheet not found: ${op.sheet || '(first sheet)'}`);
|
|
1506
|
+
if (op.op === 'rename_sheet') {
|
|
1507
|
+
const renamed = await renameWorksheet(zip, selected, op.name);
|
|
1508
|
+
sheets = await workbookSheets(zip);
|
|
1509
|
+
results.push({ op: op.op, changed: true, ...renamed });
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
if (op.op === 'delete_sheet') {
|
|
1513
|
+
const removed = await deleteWorksheet(zip, sheets, selected);
|
|
1514
|
+
sheets = await workbookSheets(zip);
|
|
1515
|
+
results.push({ op: op.op, changed: true, ...removed });
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
const sheet = selected;
|
|
1161
1519
|
let xml = await zipText(zip, sheet.path);
|
|
1162
1520
|
if (op.op === 'set_cell' || op.op === 'set_formula') {
|
|
1163
1521
|
xml = setCellInSheet(xml, op.cell, op.value, op.op === 'set_formula' ? op.formula : '');
|
|
@@ -1218,6 +1576,147 @@ async function applyXlsx(zip, operations) {
|
|
|
1218
1576
|
results.push({ op: op.op, changed: count > 0, count });
|
|
1219
1577
|
continue;
|
|
1220
1578
|
}
|
|
1579
|
+
if (op.op === 'set_style') {
|
|
1580
|
+
const target = op.range || op.cell;
|
|
1581
|
+
if (!target) throw new Error('set_style requires cell or range');
|
|
1582
|
+
const area = parseAreaRange(target);
|
|
1583
|
+
if (!area.startRow || !area.startCol) throw new Error('set_style requires a bounded cell or range such as A1 or A1:D5');
|
|
1584
|
+
const covered = (area.endRow - area.startRow + 1) * (area.endCol - area.startCol + 1);
|
|
1585
|
+
if (covered > MAX_STYLED_CELLS) {
|
|
1586
|
+
throw new Error(`set_style covers ${covered} cells; narrow the range to at most ${MAX_STYLED_CELLS}`);
|
|
1587
|
+
}
|
|
1588
|
+
const stylesPath = 'xl/styles.xml';
|
|
1589
|
+
let styles = await zipText(zip, stylesPath);
|
|
1590
|
+
if (!styles) throw new Error('Workbook is missing xl/styles.xml');
|
|
1591
|
+
const resolved = new Map();
|
|
1592
|
+
for (let row = area.startRow; row <= area.endRow; row += 1) {
|
|
1593
|
+
for (let column = area.startCol; column <= area.endCol; column += 1) {
|
|
1594
|
+
const ref = `${columnLabel(column)}${row}`;
|
|
1595
|
+
const base = existingCellStyle(xml, ref);
|
|
1596
|
+
if (!resolved.has(base)) {
|
|
1597
|
+
const applied = applyCellStyle(styles, base, op.properties || {});
|
|
1598
|
+
styles = applied.xml;
|
|
1599
|
+
resolved.set(base, applied.index);
|
|
1600
|
+
}
|
|
1601
|
+
xml = setCellStyleInSheet(xml, ref, resolved.get(base));
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
zip.file(stylesPath, styles);
|
|
1605
|
+
zip.file(sheet.path, xml);
|
|
1606
|
+
results.push({ op: op.op, changed: covered > 0, sheet: sheet.name, cells: covered });
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
if (op.op === 'merge_cells' || op.op === 'unmerge_cells') {
|
|
1610
|
+
const area = parseAreaRange(op.range);
|
|
1611
|
+
if (!area.startRow || !area.startCol) throw new Error(`${op.op} requires a bounded range such as A1:D1`);
|
|
1612
|
+
const ref = `${columnLabel(area.startCol)}${area.startRow}:${columnLabel(area.endCol)}${area.endRow}`;
|
|
1613
|
+
const current = mergedRanges(xml);
|
|
1614
|
+
const next = op.op === 'merge_cells'
|
|
1615
|
+
? [...current, ref]
|
|
1616
|
+
: current.filter((entry) => entry !== ref);
|
|
1617
|
+
const changed = new Set(next).size !== new Set(current).size;
|
|
1618
|
+
xml = writeMergedRanges(xml, next);
|
|
1619
|
+
zip.file(sheet.path, xml);
|
|
1620
|
+
results.push({ op: op.op, changed, sheet: sheet.name, range: ref });
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
if (op.op === 'freeze_panes') {
|
|
1624
|
+
const pane = freezePaneXml(op.row, op.column);
|
|
1625
|
+
xml = updateSheetView(xml, (view) => {
|
|
1626
|
+
const { attrs, body } = sheetViewParts(view);
|
|
1627
|
+
const stripped = body.replace(/<pane\b[^>]*?(?:\/>|>[\s\S]*?<\/pane>)/, '');
|
|
1628
|
+
return composeSheetView(attrs, `${pane}${stripped}`);
|
|
1629
|
+
});
|
|
1630
|
+
zip.file(sheet.path, xml);
|
|
1631
|
+
results.push({ op: op.op, changed: true, sheet: sheet.name, frozen: Boolean(pane) });
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
if (op.op === 'set_sheet_view') {
|
|
1635
|
+
xml = updateSheetView(xml, (view) => {
|
|
1636
|
+
const { attrs, body } = sheetViewParts(view);
|
|
1637
|
+
let next = attrs;
|
|
1638
|
+
if (op.showGridlines != null) {
|
|
1639
|
+
next = setXmlAttribute(next, 'showGridLines', op.showGridlines === true ? '1' : '0');
|
|
1640
|
+
}
|
|
1641
|
+
if (op.zoom != null) {
|
|
1642
|
+
const zoom = Math.min(400, Math.max(10, Math.round(Number(op.zoom) || 100)));
|
|
1643
|
+
next = setXmlAttribute(next, 'zoomScale', zoom);
|
|
1644
|
+
next = setXmlAttribute(next, 'zoomScaleNormal', zoom);
|
|
1645
|
+
}
|
|
1646
|
+
return composeSheetView(next, body);
|
|
1647
|
+
});
|
|
1648
|
+
zip.file(sheet.path, xml);
|
|
1649
|
+
results.push({ op: op.op, changed: true, sheet: sheet.name });
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
if (op.op === 'autofit_range') {
|
|
1653
|
+
const area = parseAreaRange(op.range);
|
|
1654
|
+
const records = cellRecords(xml, await sharedStrings(zip));
|
|
1655
|
+
const spans = mergedRanges(xml).map((entry) => parseAreaRange(entry));
|
|
1656
|
+
const measured = new Map();
|
|
1657
|
+
for (const record of records) {
|
|
1658
|
+
const parsed = parseCellRef(record.ref);
|
|
1659
|
+
const column = columnNumber(parsed.col);
|
|
1660
|
+
if (area.startCol && (column < area.startCol || column > area.endCol)) continue;
|
|
1661
|
+
if (area.startRow && (parsed.row < area.startRow || parsed.row > area.endRow)) continue;
|
|
1662
|
+
if (spans.some((span) => span.startCol !== span.endCol
|
|
1663
|
+
&& span.startCol <= column && column <= span.endCol
|
|
1664
|
+
&& span.startRow <= parsed.row && parsed.row <= span.endRow)) continue;
|
|
1665
|
+
const text = record.formula ? String(record.cachedValue ?? '') : String(record.value ?? '');
|
|
1666
|
+
measured.set(column, Math.max(measured.get(column) || 0, displayWidth(text)));
|
|
1667
|
+
}
|
|
1668
|
+
const widths = new Map([...measured.entries()]
|
|
1669
|
+
.map(([column, width]) => [column, Math.min(80, Math.max(8, Math.round((width + 2) * 10) / 10))]));
|
|
1670
|
+
xml = writeColumnWidths(xml, widths);
|
|
1671
|
+
zip.file(sheet.path, xml);
|
|
1672
|
+
results.push({ op: op.op, changed: widths.size > 0, sheet: sheet.name, columns: widths.size });
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
if (op.op === 'set_page_setup') {
|
|
1676
|
+
const orientation = String(op.orientation || '').toLowerCase();
|
|
1677
|
+
if (orientation && !['portrait', 'landscape'].includes(orientation)) {
|
|
1678
|
+
throw new Error('set_page_setup orientation must be portrait or landscape');
|
|
1679
|
+
}
|
|
1680
|
+
const fitWide = Number(op.fitToPagesWide) || 0;
|
|
1681
|
+
const fitTall = op.fitToPagesTall == null ? null : Number(op.fitToPagesTall) || 0;
|
|
1682
|
+
if (fitWide || fitTall != null) {
|
|
1683
|
+
const existing = worksheetSection(xml, 'sheetPr');
|
|
1684
|
+
const attrs = existing ? /^<sheetPr\b([^>]*?)(?:\/>|>)/.exec(existing[0])?.[1] || '' : '';
|
|
1685
|
+
const body = existing && !existing[0].endsWith('/>')
|
|
1686
|
+
? existing[0].slice(existing[0].indexOf('>') + 1, existing[0].lastIndexOf('</sheetPr>'))
|
|
1687
|
+
: '';
|
|
1688
|
+
const cleaned = body.replace(/<pageSetUpPr\b[^>]*?\/>/, '');
|
|
1689
|
+
xml = upsertWorksheetSection(xml, 'sheetPr', `<sheetPr${attrs}>${cleaned}<pageSetUpPr fitToPage="1"/></sheetPr>`);
|
|
1690
|
+
}
|
|
1691
|
+
const centered = `${op.centerHorizontally === true ? ' horizontalCentered="1"' : ''}`
|
|
1692
|
+
+ `${op.centerVertically === true ? ' verticalCentered="1"' : ''}`;
|
|
1693
|
+
xml = upsertWorksheetSection(xml, 'printOptions', centered ? `<printOptions${centered}/>` : '');
|
|
1694
|
+
const margin = (value, fallback) => (Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) : fallback);
|
|
1695
|
+
xml = upsertWorksheetSection(xml, 'pageMargins', `<pageMargins left="${margin(op.leftMargin, 0.7)}"`
|
|
1696
|
+
+ ` right="${margin(op.rightMargin, 0.7)}" top="${margin(op.topMargin, 0.75)}"`
|
|
1697
|
+
+ ` bottom="${margin(op.bottomMargin, 0.75)}" header="0.3" footer="0.3"/>`);
|
|
1698
|
+
xml = upsertWorksheetSection(xml, 'pageSetup', `<pageSetup paperSize="9"`
|
|
1699
|
+
+ `${orientation ? ` orientation="${orientation}"` : ''}`
|
|
1700
|
+
+ `${fitWide ? ` fitToWidth="${fitWide}"` : ''}`
|
|
1701
|
+
+ `${fitTall == null ? '' : ` fitToHeight="${fitTall}"`}/>`);
|
|
1702
|
+
zip.file(sheet.path, xml);
|
|
1703
|
+
if (op.printArea) {
|
|
1704
|
+
const area = parseAreaRange(op.printArea);
|
|
1705
|
+
const reference = `${quoteSheetName(sheet.name)}!`
|
|
1706
|
+
+ absoluteRange(`${columnLabel(area.startCol)}${area.startRow}:${columnLabel(area.endCol)}${area.endRow}`);
|
|
1707
|
+
const localSheetId = sheets.findIndex((entry) => entry.name === sheet.name);
|
|
1708
|
+
const workbookPath = 'xl/workbook.xml';
|
|
1709
|
+
const workbook = await zipText(zip, workbookPath);
|
|
1710
|
+
zip.file(workbookPath, upsertDefinedName(
|
|
1711
|
+
workbook,
|
|
1712
|
+
`<definedName name="_xlnm.Print_Area" localSheetId="${localSheetId}">${xmlEncode(reference)}</definedName>`,
|
|
1713
|
+
(item) => xmlAttribute(item, 'name') === '_xlnm.Print_Area'
|
|
1714
|
+
&& Number(xmlAttribute(item, 'localSheetId')) === localSheetId,
|
|
1715
|
+
));
|
|
1716
|
+
}
|
|
1717
|
+
results.push({ op: op.op, changed: true, sheet: sheet.name });
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1221
1720
|
throw new Error(`Portable XLSX backend does not support operation: ${op.op}`);
|
|
1222
1721
|
}
|
|
1223
1722
|
if (recalculationRequired) {
|
|
@@ -1227,14 +1726,439 @@ async function applyXlsx(zip, operations) {
|
|
|
1227
1726
|
return results;
|
|
1228
1727
|
}
|
|
1229
1728
|
|
|
1729
|
+
const PRESENTATION_NAMESPACES = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"'
|
|
1730
|
+
+ ` xmlns:r="${OFFICE_RELATIONSHIP_BASE}"`
|
|
1731
|
+
+ ' xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"';
|
|
1732
|
+
const PACKAGE_RELATIONSHIP_NS = 'http://schemas.openxmlformats.org/package/2006/relationships';
|
|
1733
|
+
const SLIDE_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
|
|
1734
|
+
const NOTES_SLIDE_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml';
|
|
1735
|
+
const NOTES_MASTER_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml';
|
|
1736
|
+
const IMAGE_CONTENT_TYPES = Object.freeze({
|
|
1737
|
+
png: 'image/png',
|
|
1738
|
+
jpg: 'image/jpeg',
|
|
1739
|
+
jpeg: 'image/jpeg',
|
|
1740
|
+
gif: 'image/gif',
|
|
1741
|
+
bmp: 'image/bmp',
|
|
1742
|
+
tif: 'image/tiff',
|
|
1743
|
+
tiff: 'image/tiff',
|
|
1744
|
+
svg: 'image/svg+xml',
|
|
1745
|
+
webp: 'image/webp',
|
|
1746
|
+
});
|
|
1747
|
+
const XML_HEADER = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n';
|
|
1748
|
+
|
|
1749
|
+
function emptyGroupShape() {
|
|
1750
|
+
return '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
|
|
1751
|
+
+ '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>'
|
|
1752
|
+
+ '<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>';
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async function presentationSlides(zip) {
|
|
1756
|
+
const presentation = await zipText(zip, 'ppt/presentation.xml');
|
|
1757
|
+
const rels = relationshipMap(await zipText(zip, 'ppt/_rels/presentation.xml.rels'));
|
|
1758
|
+
const slides = [];
|
|
1759
|
+
for (const match of presentation.matchAll(/<p:sldId\b([^>]*?)\/>/g)) {
|
|
1760
|
+
const rid = /\br:id="([^"]+)"/.exec(match[1])?.[1] || '';
|
|
1761
|
+
const target = rid ? rels.get(rid) : '';
|
|
1762
|
+
if (!target) continue;
|
|
1763
|
+
slides.push({
|
|
1764
|
+
id: Number(/\bid="(\d+)"/.exec(match[1])?.[1]) || 0,
|
|
1765
|
+
rid,
|
|
1766
|
+
path: target.startsWith('/') ? target.slice(1) : posix.normalize(posix.join('ppt', target)),
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
if (slides.length) return slides;
|
|
1770
|
+
return Object.keys(zip.files)
|
|
1771
|
+
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
|
|
1772
|
+
.sort((left, right) => Number(/(\d+)\.xml$/.exec(left)[1]) - Number(/(\d+)\.xml$/.exec(right)[1]))
|
|
1773
|
+
.map((path) => ({ id: 0, rid: '', path }));
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
function slidePath(slides, number) {
|
|
1777
|
+
const slide = slides[Number(number) - 1];
|
|
1778
|
+
if (!slide) throw new Error(`PPTX slide ${number} not found`);
|
|
1779
|
+
return slide.path;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
async function ensureContentTypeOverride(zip, part, type) {
|
|
1783
|
+
const path = '[Content_Types].xml';
|
|
1784
|
+
const xml = await zipText(zip, path);
|
|
1785
|
+
if (xml.includes(`PartName="${part}"`)) return;
|
|
1786
|
+
zip.file(path, xml.replace('</Types>', `<Override PartName="${part}" ContentType="${type}"/></Types>`));
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
async function removeContentTypeOverride(zip, part) {
|
|
1790
|
+
const path = '[Content_Types].xml';
|
|
1791
|
+
const xml = await zipText(zip, path);
|
|
1792
|
+
zip.file(path, xml.replace(new RegExp(`<Override\\b[^>]*\\bPartName="${tagPattern(part)}"[^>]*\\/>`), ''));
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
async function ensureDefaultContentType(zip, extension, type) {
|
|
1796
|
+
const path = '[Content_Types].xml';
|
|
1797
|
+
const xml = await zipText(zip, path);
|
|
1798
|
+
if (new RegExp(`<Default\\b[^>]*\\bExtension="${extension}"`, 'i').test(xml)) return;
|
|
1799
|
+
zip.file(path, xml.replace(/<Types\b[^>]*>/, `$&<Default Extension="${extension}" ContentType="${type}"/>`));
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
async function addPackageRelationship(zip, relsPath, type, target) {
|
|
1803
|
+
const existing = await zipText(zip, relsPath);
|
|
1804
|
+
const xml = existing || `${XML_HEADER}<Relationships xmlns="${PACKAGE_RELATIONSHIP_NS}"></Relationships>`;
|
|
1805
|
+
const id = nextRelationshipId(xml);
|
|
1806
|
+
zip.file(relsPath, xml.replace('</Relationships>', `<Relationship Id="${id}" Type="${type}" Target="${xmlEncode(target)}"/></Relationships>`));
|
|
1807
|
+
return id;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
async function removePackageRelationship(zip, relsPath, id) {
|
|
1811
|
+
const xml = await zipText(zip, relsPath);
|
|
1812
|
+
if (!xml) return;
|
|
1813
|
+
zip.file(relsPath, xml.replace(new RegExp(`<Relationship\\b[^>]*\\bId="${tagPattern(id)}"[^>]*\\/>`), ''));
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function partRelationshipPath(part) {
|
|
1817
|
+
return `${posix.dirname(part)}/_rels/${posix.basename(part)}.rels`;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
async function slideLayoutParts(zip) {
|
|
1821
|
+
const paths = Object.keys(zip.files)
|
|
1822
|
+
.filter((name) => /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(name))
|
|
1823
|
+
.sort((left, right) => Number(/(\d+)\.xml$/.exec(left)[1]) - Number(/(\d+)\.xml$/.exec(right)[1]));
|
|
1824
|
+
const layouts = [];
|
|
1825
|
+
for (const path of paths) {
|
|
1826
|
+
const xml = await zipText(zip, path);
|
|
1827
|
+
layouts.push({
|
|
1828
|
+
path,
|
|
1829
|
+
name: xmlDecode(/<p:cSld\b[^>]*\bname="([^"]*)"/.exec(xml)?.[1] || ''),
|
|
1830
|
+
type: /<p:sldLayout\b[^>]*\btype="([^"]*)"/.exec(xml)?.[1] || '',
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
return layouts;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function selectSlideLayout(layouts, requested) {
|
|
1837
|
+
if (!layouts.length) throw new Error('Presentation has no slide layout for a new slide');
|
|
1838
|
+
const value = String(requested ?? '').trim();
|
|
1839
|
+
if (!value) return layouts.find((layout) => layout.type === 'blank') || layouts[0];
|
|
1840
|
+
if (/^\d+$/.test(value)) {
|
|
1841
|
+
const found = layouts[Number(value) - 1];
|
|
1842
|
+
if (!found) throw new Error(`Slide layout ${value} not found`);
|
|
1843
|
+
return found;
|
|
1844
|
+
}
|
|
1845
|
+
const matched = layouts.find((layout) => layout.name.toLowerCase() === value.toLowerCase())
|
|
1846
|
+
|| layouts.find((layout) => layout.type.toLowerCase() === value.toLowerCase());
|
|
1847
|
+
if (!matched) {
|
|
1848
|
+
throw new Error(`Slide layout not found: ${value}. Available: ${layouts.map((layout) => layout.name || layout.type || '(unnamed)').join(', ')}`);
|
|
1849
|
+
}
|
|
1850
|
+
return matched;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function writeSlideIdList(presentation, entries) {
|
|
1854
|
+
const element = `<p:sldIdLst>${entries.join('')}</p:sldIdLst>`;
|
|
1855
|
+
const existing = /<p:sldIdLst\b[^>]*?(?:\/>|>[\s\S]*?<\/p:sldIdLst>)/.exec(presentation);
|
|
1856
|
+
if (existing) {
|
|
1857
|
+
return `${presentation.slice(0, existing.index)}${element}${presentation.slice(existing.index + existing[0].length)}`;
|
|
1858
|
+
}
|
|
1859
|
+
const size = /<p:sldSz\b/.exec(presentation);
|
|
1860
|
+
if (size) return `${presentation.slice(0, size.index)}${element}${presentation.slice(size.index)}`;
|
|
1861
|
+
return presentation.replace('</p:presentation>', `${element}</p:presentation>`);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function slideIdEntries(presentation) {
|
|
1865
|
+
const list = /<p:sldIdLst\b[^>]*?(?:\/>|>[\s\S]*?<\/p:sldIdLst>)/.exec(presentation);
|
|
1866
|
+
return list ? [...list[0].matchAll(/<p:sldId\b[^>]*?\/>/g)].map((match) => match[0]) : [];
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
async function addPresentationSlide(zip, op) {
|
|
1870
|
+
const layout = selectSlideLayout(await slideLayoutParts(zip), op.layout);
|
|
1871
|
+
let ordinal = 1;
|
|
1872
|
+
while (zip.file(`ppt/slides/slide${ordinal}.xml`)) ordinal += 1;
|
|
1873
|
+
const part = `ppt/slides/slide${ordinal}.xml`;
|
|
1874
|
+
zip.file(part, `${XML_HEADER}<p:sld ${PRESENTATION_NAMESPACES}><p:cSld><p:spTree>${emptyGroupShape()}`
|
|
1875
|
+
+ '</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>');
|
|
1876
|
+
zip.file(partRelationshipPath(part), `${XML_HEADER}<Relationships xmlns="${PACKAGE_RELATIONSHIP_NS}">`
|
|
1877
|
+
+ `<Relationship Id="rId1" Type="${OFFICE_RELATIONSHIP_BASE}/slideLayout" Target="${posix.relative('ppt/slides', layout.path)}"/>`
|
|
1878
|
+
+ '</Relationships>');
|
|
1879
|
+
await ensureContentTypeOverride(zip, `/${part}`, SLIDE_CONTENT_TYPE);
|
|
1880
|
+
const relationshipId = await addPackageRelationship(
|
|
1881
|
+
zip,
|
|
1882
|
+
'ppt/_rels/presentation.xml.rels',
|
|
1883
|
+
`${OFFICE_RELATIONSHIP_BASE}/slide`,
|
|
1884
|
+
`slides/slide${ordinal}.xml`,
|
|
1885
|
+
);
|
|
1886
|
+
const presentationPath = 'ppt/presentation.xml';
|
|
1887
|
+
const presentation = await zipText(zip, presentationPath);
|
|
1888
|
+
const entries = slideIdEntries(presentation);
|
|
1889
|
+
const ids = entries.map((entry) => Number(xmlAttribute(entry, 'id')) || 0);
|
|
1890
|
+
const position = Number(op.index) > 0 ? Math.min(Number(op.index) - 1, entries.length) : entries.length;
|
|
1891
|
+
entries.splice(position, 0, `<p:sldId id="${Math.max(255, ...ids) + 1}" r:id="${relationshipId}"/>`);
|
|
1892
|
+
zip.file(presentationPath, writeSlideIdList(presentation, entries));
|
|
1893
|
+
return { part, position: position + 1, layout: layout.name || layout.type || layout.path };
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
async function deletePresentationSlide(zip, slides, number) {
|
|
1897
|
+
if (slides.length <= 1) throw new Error('A presentation must keep at least one slide');
|
|
1898
|
+
const slide = slides[Number(number) - 1];
|
|
1899
|
+
if (!slide) throw new Error(`PPTX slide ${number} not found`);
|
|
1900
|
+
const presentationPath = 'ppt/presentation.xml';
|
|
1901
|
+
const presentation = await zipText(zip, presentationPath);
|
|
1902
|
+
const entries = slideIdEntries(presentation)
|
|
1903
|
+
.filter((entry) => xmlAttribute(entry, 'r:id') !== slide.rid);
|
|
1904
|
+
zip.file(presentationPath, writeSlideIdList(presentation, entries));
|
|
1905
|
+
await removePackageRelationship(zip, 'ppt/_rels/presentation.xml.rels', slide.rid);
|
|
1906
|
+
const relsPath = partRelationshipPath(slide.path);
|
|
1907
|
+
const rels = await zipText(zip, relsPath);
|
|
1908
|
+
const notes = /<Relationship\b[^>]*\bType="[^"]*\/notesSlide"[^>]*\bTarget="([^"]+)"/.exec(rels)?.[1];
|
|
1909
|
+
if (notes) {
|
|
1910
|
+
const notesPart = posix.normalize(posix.join(posix.dirname(slide.path), notes));
|
|
1911
|
+
zip.remove(notesPart);
|
|
1912
|
+
if (zip.file(partRelationshipPath(notesPart))) zip.remove(partRelationshipPath(notesPart));
|
|
1913
|
+
await removeContentTypeOverride(zip, `/${notesPart}`);
|
|
1914
|
+
}
|
|
1915
|
+
zip.remove(slide.path);
|
|
1916
|
+
if (zip.file(relsPath)) zip.remove(relsPath);
|
|
1917
|
+
await removeContentTypeOverride(zip, `/${slide.path}`);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
async function movePresentationSlide(zip, slides, number, index) {
|
|
1921
|
+
const slide = slides[Number(number) - 1];
|
|
1922
|
+
if (!slide) throw new Error(`PPTX slide ${number} not found`);
|
|
1923
|
+
const target = Number(index);
|
|
1924
|
+
if (!Number.isFinite(target) || target < 1 || target > slides.length) {
|
|
1925
|
+
throw new Error(`move_slide index must be between 1 and ${slides.length}`);
|
|
1926
|
+
}
|
|
1927
|
+
const presentationPath = 'ppt/presentation.xml';
|
|
1928
|
+
const presentation = await zipText(zip, presentationPath);
|
|
1929
|
+
const entries = slideIdEntries(presentation);
|
|
1930
|
+
const from = entries.findIndex((entry) => xmlAttribute(entry, 'r:id') === slide.rid);
|
|
1931
|
+
if (from < 0) throw new Error(`PPTX slide ${number} is not listed in the presentation`);
|
|
1932
|
+
const [moved] = entries.splice(from, 1);
|
|
1933
|
+
entries.splice(target - 1, 0, moved);
|
|
1934
|
+
zip.file(presentationPath, writeSlideIdList(presentation, entries));
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
async function ensureNotesMaster(zip) {
|
|
1938
|
+
const part = 'ppt/notesMasters/notesMaster1.xml';
|
|
1939
|
+
if (zip.file(part)) return part;
|
|
1940
|
+
const theme = Object.keys(zip.files).find((name) => /^ppt\/theme\/theme\d+\.xml$/.test(name));
|
|
1941
|
+
if (!theme) throw new Error('Presentation is missing a theme for the notes master');
|
|
1942
|
+
const colorMap = 'bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2"'
|
|
1943
|
+
+ ' accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"';
|
|
1944
|
+
zip.file(part, `${XML_HEADER}<p:notesMaster ${PRESENTATION_NAMESPACES}>`
|
|
1945
|
+
+ `<p:cSld><p:spTree>${emptyGroupShape()}</p:spTree></p:cSld>`
|
|
1946
|
+
+ `<p:clrMap ${colorMap}/>`
|
|
1947
|
+
+ '<p:notesStyle><a:lvl1pPr><a:defRPr sz="1200"/></a:lvl1pPr></p:notesStyle>'
|
|
1948
|
+
+ '</p:notesMaster>');
|
|
1949
|
+
zip.file(partRelationshipPath(part), `${XML_HEADER}<Relationships xmlns="${PACKAGE_RELATIONSHIP_NS}">`
|
|
1950
|
+
+ `<Relationship Id="rId1" Type="${OFFICE_RELATIONSHIP_BASE}/theme" Target="${posix.relative('ppt/notesMasters', theme)}"/>`
|
|
1951
|
+
+ '</Relationships>');
|
|
1952
|
+
await ensureContentTypeOverride(zip, `/${part}`, NOTES_MASTER_CONTENT_TYPE);
|
|
1953
|
+
const relationshipId = await addPackageRelationship(
|
|
1954
|
+
zip,
|
|
1955
|
+
'ppt/_rels/presentation.xml.rels',
|
|
1956
|
+
`${OFFICE_RELATIONSHIP_BASE}/notesMaster`,
|
|
1957
|
+
'notesMasters/notesMaster1.xml',
|
|
1958
|
+
);
|
|
1959
|
+
const presentationPath = 'ppt/presentation.xml';
|
|
1960
|
+
const presentation = await zipText(zip, presentationPath);
|
|
1961
|
+
if (!/<p:notesMasterIdLst\b/.test(presentation)) {
|
|
1962
|
+
const element = `<p:notesMasterIdLst><p:notesMasterId r:id="${relationshipId}"/></p:notesMasterIdLst>`;
|
|
1963
|
+
const anchor = /<p:sldIdLst\b/.exec(presentation) || /<p:sldSz\b/.exec(presentation);
|
|
1964
|
+
zip.file(presentationPath, anchor
|
|
1965
|
+
? `${presentation.slice(0, anchor.index)}${element}${presentation.slice(anchor.index)}`
|
|
1966
|
+
: presentation.replace('</p:presentation>', `${element}</p:presentation>`));
|
|
1967
|
+
}
|
|
1968
|
+
return part;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
async function setSlideNotes(zip, slides, number, text) {
|
|
1972
|
+
const slide = slides[Number(number) - 1];
|
|
1973
|
+
if (!slide) throw new Error(`PPTX slide ${number} not found`);
|
|
1974
|
+
await ensureNotesMaster(zip);
|
|
1975
|
+
const slideRelsPath = partRelationshipPath(slide.path);
|
|
1976
|
+
const slideRels = await zipText(zip, slideRelsPath);
|
|
1977
|
+
const linked = /<Relationship\b[^>]*\bType="[^"]*\/notesSlide"[^>]*\bTarget="([^"]+)"/.exec(slideRels)?.[1];
|
|
1978
|
+
const ordinal = Number(/slide(\d+)\.xml$/.exec(slide.path)?.[1]) || Number(number);
|
|
1979
|
+
const part = linked
|
|
1980
|
+
? posix.normalize(posix.join(posix.dirname(slide.path), linked))
|
|
1981
|
+
: `ppt/notesSlides/notesSlide${ordinal}.xml`;
|
|
1982
|
+
const paragraphs = String(text ?? '').split(/\r?\n/).map((line) => ({ text: line }));
|
|
1983
|
+
const body = textBodyXml({ paragraphs, defaults: { fontSize: 12 } });
|
|
1984
|
+
zip.file(part, `${XML_HEADER}<p:notes ${PRESENTATION_NAMESPACES}><p:cSld><p:spTree>${emptyGroupShape()}`
|
|
1985
|
+
+ '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Notes Placeholder"/>'
|
|
1986
|
+
+ '<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>'
|
|
1987
|
+
+ '<p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>'
|
|
1988
|
+
+ `<p:spPr/><p:txBody>${body}</p:txBody></p:sp>`
|
|
1989
|
+
+ '</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>');
|
|
1990
|
+
if (!zip.file(partRelationshipPath(part))) {
|
|
1991
|
+
zip.file(partRelationshipPath(part), `${XML_HEADER}<Relationships xmlns="${PACKAGE_RELATIONSHIP_NS}">`
|
|
1992
|
+
+ `<Relationship Id="rId1" Type="${OFFICE_RELATIONSHIP_BASE}/slide" Target="${posix.relative(posix.dirname(part), slide.path)}"/>`
|
|
1993
|
+
+ `<Relationship Id="rId2" Type="${OFFICE_RELATIONSHIP_BASE}/notesMaster" Target="${posix.relative(posix.dirname(part), 'ppt/notesMasters/notesMaster1.xml')}"/>`
|
|
1994
|
+
+ '</Relationships>');
|
|
1995
|
+
}
|
|
1996
|
+
await ensureContentTypeOverride(zip, `/${part}`, NOTES_SLIDE_CONTENT_TYPE);
|
|
1997
|
+
if (!linked) {
|
|
1998
|
+
await addPackageRelationship(
|
|
1999
|
+
zip,
|
|
2000
|
+
slideRelsPath,
|
|
2001
|
+
`${OFFICE_RELATIONSHIP_BASE}/notesSlide`,
|
|
2002
|
+
posix.relative(posix.dirname(slide.path), part),
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
async function addSlideImage(zip, slidePart, source) {
|
|
2008
|
+
const extension = extname(String(source || '')).replace(/^\./, '').toLowerCase();
|
|
2009
|
+
const contentType = IMAGE_CONTENT_TYPES[extension];
|
|
2010
|
+
if (!contentType) {
|
|
2011
|
+
throw new Error(`Unsupported image type: .${extension || 'unknown'}. Use ${Object.keys(IMAGE_CONTENT_TYPES).join(', ')}`);
|
|
2012
|
+
}
|
|
2013
|
+
const data = await readFile(source);
|
|
2014
|
+
let ordinal = 1;
|
|
2015
|
+
while (zip.file(`ppt/media/image${ordinal}.${extension}`)) ordinal += 1;
|
|
2016
|
+
const part = `ppt/media/image${ordinal}.${extension}`;
|
|
2017
|
+
zip.file(part, data);
|
|
2018
|
+
await ensureDefaultContentType(zip, extension, contentType);
|
|
2019
|
+
const relationshipId = await addPackageRelationship(
|
|
2020
|
+
zip,
|
|
2021
|
+
partRelationshipPath(slidePart),
|
|
2022
|
+
`${OFFICE_RELATIONSHIP_BASE}/image`,
|
|
2023
|
+
posix.relative(posix.dirname(slidePart), part),
|
|
2024
|
+
);
|
|
2025
|
+
return { part, relationshipId };
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
function setTableCellText(cell, text) {
|
|
2029
|
+
const value = String(text ?? '');
|
|
2030
|
+
const nodes = textNodes(cell, 'a:t');
|
|
2031
|
+
if (nodes.length) {
|
|
2032
|
+
nodes[0].text = value;
|
|
2033
|
+
for (let index = 1; index < nodes.length; index += 1) nodes[index].text = '';
|
|
2034
|
+
return rebuildTextNodes(cell, 'a:t', nodes);
|
|
2035
|
+
}
|
|
2036
|
+
const run = `<a:r><a:rPr lang="en-US" dirty="0"/>`
|
|
2037
|
+
+ `<a:t${/^\s|\s$/.test(value) ? ' xml:space="preserve"' : ''}>${xmlEncode(value)}</a:t></a:r>`;
|
|
2038
|
+
const paragraph = /<a:p(?:\s[^>]*)?>[\s\S]*?<\/a:p>/.exec(cell);
|
|
2039
|
+
if (paragraph) {
|
|
2040
|
+
const replaced = paragraph[0].replace(/<\/a:p>$/, `${run}</a:p>`);
|
|
2041
|
+
return `${cell.slice(0, paragraph.index)}${replaced}${cell.slice(paragraph.index + paragraph[0].length)}`;
|
|
2042
|
+
}
|
|
2043
|
+
if (!/<\/a:txBody>/.test(cell)) throw new Error('PPTX table cell has no text body');
|
|
2044
|
+
return cell.replace('</a:txBody>', `<a:p>${run}</a:p></a:txBody>`);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function setTableValues(shapeXml, values) {
|
|
2048
|
+
const table = containerInner(shapeXml, 'a:tbl');
|
|
2049
|
+
if (!table) throw new Error('PPTX shape does not contain a table');
|
|
2050
|
+
const rows = elementSpans(table.inner, 'a:tr');
|
|
2051
|
+
if (!rows.length) throw new Error('PPTX table has no rows');
|
|
2052
|
+
let inner = table.inner;
|
|
2053
|
+
let filledRows = 0;
|
|
2054
|
+
let filledCells = 0;
|
|
2055
|
+
for (let rowIndex = rows.length - 1; rowIndex >= 0; rowIndex -= 1) {
|
|
2056
|
+
const source = values[rowIndex];
|
|
2057
|
+
if (!Array.isArray(source)) continue;
|
|
2058
|
+
const row = rows[rowIndex];
|
|
2059
|
+
const cells = elementSpans(containerBody(row.xml, 'a:tr'), 'a:tc');
|
|
2060
|
+
let body = containerBody(row.xml, 'a:tr');
|
|
2061
|
+
for (let cellIndex = cells.length - 1; cellIndex >= 0; cellIndex -= 1) {
|
|
2062
|
+
if (cellIndex >= source.length) continue;
|
|
2063
|
+
const cell = cells[cellIndex];
|
|
2064
|
+
body = `${body.slice(0, cell.start)}${setTableCellText(cell.xml, source[cellIndex])}${body.slice(cell.end)}`;
|
|
2065
|
+
filledCells += 1;
|
|
2066
|
+
}
|
|
2067
|
+
const attrs = /^<a:tr\b([^>]*?)(?:\/>|>)/.exec(row.xml)?.[1] || '';
|
|
2068
|
+
inner = `${inner.slice(0, row.start)}<a:tr${attrs}>${body}</a:tr>${inner.slice(row.end)}`;
|
|
2069
|
+
filledRows += 1;
|
|
2070
|
+
}
|
|
2071
|
+
return {
|
|
2072
|
+
xml: `${shapeXml.slice(0, table.start)}${inner}${shapeXml.slice(table.end)}`,
|
|
2073
|
+
rows: filledRows,
|
|
2074
|
+
cells: filledCells,
|
|
2075
|
+
capacity: rows.length,
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
function appendSlideShape(xml, shape) {
|
|
2080
|
+
if (!/<\/p:spTree>/.test(xml)) throw new Error('PPTX slide shape tree is missing');
|
|
2081
|
+
return xml.replace('</p:spTree>', `${shape}</p:spTree>`);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
function setSlideBackground(xml, color) {
|
|
2085
|
+
const background = backgroundXml(color);
|
|
2086
|
+
const existing = /<p:bg\b[^>]*?(?:\/>|>[\s\S]*?<\/p:bg>)/.exec(xml);
|
|
2087
|
+
if (existing) {
|
|
2088
|
+
return `${xml.slice(0, existing.index)}${background}${xml.slice(existing.index + existing[0].length)}`;
|
|
2089
|
+
}
|
|
2090
|
+
const common = /<p:cSld\b[^>]*?>/.exec(xml);
|
|
2091
|
+
if (!common) throw new Error('PPTX slide is missing its common slide data');
|
|
2092
|
+
const position = common.index + common[0].length;
|
|
2093
|
+
return `${xml.slice(0, position)}${background}${xml.slice(position)}`;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function updateShapeGeometry(shape, properties) {
|
|
2097
|
+
let next = shape;
|
|
2098
|
+
if (['left', 'top', 'width', 'height', 'rotation'].some((key) => properties[key] != null)) {
|
|
2099
|
+
const current = /<a:xfrm\b[^>]*?(?:\/>|>[\s\S]*?<\/a:xfrm>)/.exec(next);
|
|
2100
|
+
const offset = current ? /<a:off\b[^>]*\bx="(-?\d+)"[^>]*\by="(-?\d+)"/.exec(current[0]) : null;
|
|
2101
|
+
const extent = current ? /<a:ext\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(current[0]) : null;
|
|
2102
|
+
const rotation = properties.rotation != null
|
|
2103
|
+
? Math.round(Number(properties.rotation) * 60_000)
|
|
2104
|
+
: Number(current ? xmlAttribute(current[0], 'rot') : 0) || 0;
|
|
2105
|
+
const frame = `<a:xfrm${rotation ? ` rot="${rotation}"` : ''}>`
|
|
2106
|
+
+ `<a:off x="${properties.left != null ? toEmu(properties.left) : Number(offset?.[1] || 0)}"`
|
|
2107
|
+
+ ` y="${properties.top != null ? toEmu(properties.top) : Number(offset?.[2] || 0)}"/>`
|
|
2108
|
+
+ `<a:ext cx="${properties.width != null ? toEmu(properties.width) : Number(extent?.[1] || 1)}"`
|
|
2109
|
+
+ ` cy="${properties.height != null ? toEmu(properties.height) : Number(extent?.[2] || 1)}"/></a:xfrm>`;
|
|
2110
|
+
next = current
|
|
2111
|
+
? `${next.slice(0, current.index)}${frame}${next.slice(current.index + current[0].length)}`
|
|
2112
|
+
: next.replace(/<p:spPr(?:\s[^>]*)?>/, `$&${frame}`);
|
|
2113
|
+
}
|
|
2114
|
+
if (properties.fillColor != null) {
|
|
2115
|
+
const shapeProperties = containerInner(next, 'p:spPr');
|
|
2116
|
+
const fill = solidFillXml(properties.fillColor, properties.fillTransparency);
|
|
2117
|
+
if (shapeProperties && fill) {
|
|
2118
|
+
const cleaned = shapeProperties.inner
|
|
2119
|
+
.replace(/<a:solidFill\b[^>]*?(?:\/>|>[\s\S]*?<\/a:solidFill>)/, '')
|
|
2120
|
+
.replace(/<a:noFill\s*\/>/, '');
|
|
2121
|
+
const geometry = /<a:prstGeom\b[^>]*?(?:\/>|>[\s\S]*?<\/a:prstGeom>)/.exec(cleaned);
|
|
2122
|
+
const position = geometry ? geometry.index + geometry[0].length : cleaned.length;
|
|
2123
|
+
const inner = `${cleaned.slice(0, position)}${fill}${cleaned.slice(position)}`;
|
|
2124
|
+
next = `${next.slice(0, shapeProperties.start)}${inner}${next.slice(shapeProperties.end)}`;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
return next;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
1230
2130
|
function nextShapeId(xml) {
|
|
1231
2131
|
const ids = [...xml.matchAll(/\bcNvPr\s+id="(\d+)"/g)].map((match) => Number(match[1]));
|
|
1232
2132
|
return Math.max(1, ...ids) + 1;
|
|
1233
2133
|
}
|
|
1234
2134
|
|
|
1235
2135
|
async function applyPptx(zip, operations) {
|
|
2136
|
+
let slides = await presentationSlides(zip);
|
|
1236
2137
|
const results = [];
|
|
1237
2138
|
for (const op of operations) {
|
|
2139
|
+
if (op.op === 'add_slide') {
|
|
2140
|
+
const created = await addPresentationSlide(zip, op);
|
|
2141
|
+
slides = await presentationSlides(zip);
|
|
2142
|
+
results.push({ op: op.op, changed: true, slide: created.position, layout: created.layout });
|
|
2143
|
+
continue;
|
|
2144
|
+
}
|
|
2145
|
+
if (op.op === 'delete_slide') {
|
|
2146
|
+
await deletePresentationSlide(zip, slides, op.slide);
|
|
2147
|
+
slides = await presentationSlides(zip);
|
|
2148
|
+
results.push({ op: op.op, changed: true, slide: Number(op.slide) });
|
|
2149
|
+
continue;
|
|
2150
|
+
}
|
|
2151
|
+
if (op.op === 'move_slide') {
|
|
2152
|
+
await movePresentationSlide(zip, slides, op.slide, op.index);
|
|
2153
|
+
slides = await presentationSlides(zip);
|
|
2154
|
+
results.push({ op: op.op, changed: true, slide: Number(op.slide), index: Number(op.index) });
|
|
2155
|
+
continue;
|
|
2156
|
+
}
|
|
2157
|
+
if (op.op === 'set_notes') {
|
|
2158
|
+
await setSlideNotes(zip, slides, op.slide, op.text);
|
|
2159
|
+
results.push({ op: op.op, changed: true, slide: Number(op.slide) });
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
1238
2162
|
if (op.op === 'fill_template') {
|
|
1239
2163
|
const paths = Object.keys(zip.files).filter((name) => /^ppt\/(slides\/slide\d+|notesSlides\/notesSlide\d+)\.xml$/.test(name));
|
|
1240
2164
|
results.push(await fillTemplateParts(zip, paths, 'a:t', op));
|
|
@@ -1253,9 +2177,8 @@ async function applyPptx(zip, operations) {
|
|
|
1253
2177
|
continue;
|
|
1254
2178
|
}
|
|
1255
2179
|
if (op.op === 'set_text') {
|
|
1256
|
-
const path =
|
|
2180
|
+
const path = slidePath(slides, op.slide);
|
|
1257
2181
|
const current = await zipText(zip, path);
|
|
1258
|
-
if (!current) throw new Error(`PPTX slide ${op.slide} not found`);
|
|
1259
2182
|
const shapes = [...current.matchAll(/<p:sp(?:\s[^>]*)?>[\s\S]*?<\/p:sp>/g)];
|
|
1260
2183
|
const shape = shapes[Number(op.shape) - 1];
|
|
1261
2184
|
if (!shape) throw new Error(`PPTX shape ${op.shape} not found on slide ${op.slide}`);
|
|
@@ -1268,26 +2191,56 @@ async function applyPptx(zip, operations) {
|
|
|
1268
2191
|
results.push({ op: op.op, changed: true });
|
|
1269
2192
|
continue;
|
|
1270
2193
|
}
|
|
1271
|
-
if (op.op === 'add_textbox') {
|
|
1272
|
-
const path =
|
|
2194
|
+
if (op.op === 'add_textbox' || op.op === 'add_shape') {
|
|
2195
|
+
const path = slidePath(slides, op.slide);
|
|
1273
2196
|
const current = await zipText(zip, path);
|
|
1274
|
-
if (!current) throw new Error(`PPTX slide ${op.slide} not found`);
|
|
1275
2197
|
const id = nextShapeId(current);
|
|
1276
|
-
const
|
|
1277
|
-
const
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
2198
|
+
const properties = op.properties || {};
|
|
2199
|
+
const textBox = op.op === 'add_textbox';
|
|
2200
|
+
const geometry = textBox ? 'rect' : resolveGeometry(op.shapeType);
|
|
2201
|
+
if (!geometry) {
|
|
2202
|
+
throw new Error(`Unsupported shapeType: ${op.shapeType}. Use one of: ${supportedShapeTypes().join(', ')}`);
|
|
2203
|
+
}
|
|
2204
|
+
const paragraphs = Array.isArray(op.paragraphs) && op.paragraphs.length
|
|
2205
|
+
? op.paragraphs
|
|
2206
|
+
: [{ text: String(op.text ?? '') }];
|
|
2207
|
+
const shape = shapeXml({
|
|
2208
|
+
id,
|
|
2209
|
+
name: `Mixdog ${textBox ? 'TextBox' : 'Shape'} ${id}`,
|
|
2210
|
+
geometry,
|
|
2211
|
+
left: op.left ?? properties.left ?? 72,
|
|
2212
|
+
top: op.top ?? properties.top ?? 72,
|
|
2213
|
+
width: op.width ?? properties.width ?? 360,
|
|
2214
|
+
height: op.height ?? properties.height ?? 72,
|
|
2215
|
+
properties: {
|
|
2216
|
+
...properties,
|
|
2217
|
+
...(op.fillColor == null ? {} : { fillColor: op.fillColor }),
|
|
2218
|
+
...(op.lineColor === undefined ? {} : { lineColor: op.lineColor }),
|
|
2219
|
+
},
|
|
2220
|
+
textBody: textBodyXml({
|
|
2221
|
+
paragraphs,
|
|
2222
|
+
defaults: {
|
|
2223
|
+
fontName: op.fontName ?? properties.fontName,
|
|
2224
|
+
fontSize: op.fontSize ?? properties.fontSize ?? 18,
|
|
2225
|
+
color: op.color ?? properties.color,
|
|
2226
|
+
bold: properties.bold,
|
|
2227
|
+
italic: properties.italic,
|
|
2228
|
+
align: properties.align,
|
|
2229
|
+
paragraphSpacing: properties.paragraphSpacing,
|
|
2230
|
+
},
|
|
2231
|
+
anchor: properties.anchor || (textBox ? '' : 'center'),
|
|
2232
|
+
margins: properties,
|
|
2233
|
+
autofit: properties.autofit || 'none',
|
|
2234
|
+
}),
|
|
2235
|
+
textBox,
|
|
2236
|
+
});
|
|
2237
|
+
zip.file(path, appendSlideShape(current, shape));
|
|
1284
2238
|
results.push({ op: op.op, changed: true, shapeId: id });
|
|
1285
2239
|
continue;
|
|
1286
2240
|
}
|
|
1287
2241
|
if (op.op === 'delete_shape') {
|
|
1288
|
-
const path =
|
|
2242
|
+
const path = slidePath(slides, op.slide);
|
|
1289
2243
|
const current = await zipText(zip, path);
|
|
1290
|
-
if (!current) throw new Error(`PPTX slide ${op.slide} not found`);
|
|
1291
2244
|
const tree = containerInner(current, 'p:spTree');
|
|
1292
2245
|
if (!tree) throw new Error('PPTX slide shape tree is missing');
|
|
1293
2246
|
const shapes = topLevelElements(tree.inner, ['p:sp', 'p:pic', 'p:graphicFrame', 'p:grpSp']);
|
|
@@ -1298,6 +2251,96 @@ async function applyPptx(zip, operations) {
|
|
|
1298
2251
|
results.push({ op: op.op, changed: true });
|
|
1299
2252
|
continue;
|
|
1300
2253
|
}
|
|
2254
|
+
if (op.op === 'import_slides') {
|
|
2255
|
+
throw new Error('Portable import_slides seeds a deck only as the first operation of a newly created presentation; run it in the create batch or use Microsoft PowerPoint');
|
|
2256
|
+
}
|
|
2257
|
+
if (op.op === 'set_slide_background') {
|
|
2258
|
+
const path = slidePath(slides, op.slide);
|
|
2259
|
+
const current = await zipText(zip, path);
|
|
2260
|
+
zip.file(path, setSlideBackground(current, op.color));
|
|
2261
|
+
results.push({ op: op.op, changed: true, slide: Number(op.slide) });
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
if (op.op === 'add_table') {
|
|
2265
|
+
const path = slidePath(slides, op.slide);
|
|
2266
|
+
const current = await zipText(zip, path);
|
|
2267
|
+
const id = nextShapeId(current);
|
|
2268
|
+
const table = tableXml({
|
|
2269
|
+
id,
|
|
2270
|
+
values: Array.isArray(op.values) ? op.values : [],
|
|
2271
|
+
left: op.left ?? 72,
|
|
2272
|
+
top: op.top ?? 72,
|
|
2273
|
+
width: op.width ?? 480,
|
|
2274
|
+
height: op.height ?? 120,
|
|
2275
|
+
properties: op.properties || {},
|
|
2276
|
+
});
|
|
2277
|
+
zip.file(path, appendSlideShape(current, table));
|
|
2278
|
+
results.push({ op: op.op, changed: true, shapeId: id });
|
|
2279
|
+
continue;
|
|
2280
|
+
}
|
|
2281
|
+
if (op.op === 'add_image') {
|
|
2282
|
+
const path = slidePath(slides, op.slide);
|
|
2283
|
+
const media = await addSlideImage(zip, path, op.path);
|
|
2284
|
+
const current = await zipText(zip, path);
|
|
2285
|
+
const id = nextShapeId(current);
|
|
2286
|
+
const picture = pictureXml({
|
|
2287
|
+
id,
|
|
2288
|
+
embedId: media.relationshipId,
|
|
2289
|
+
left: op.left ?? 72,
|
|
2290
|
+
top: op.top ?? 72,
|
|
2291
|
+
width: op.width ?? 240,
|
|
2292
|
+
height: op.height ?? 180,
|
|
2293
|
+
});
|
|
2294
|
+
zip.file(path, appendSlideShape(current, picture));
|
|
2295
|
+
results.push({ op: op.op, changed: true, shapeId: id, image: media.part });
|
|
2296
|
+
continue;
|
|
2297
|
+
}
|
|
2298
|
+
if (op.op === 'set_table_data' || op.op === 'replace_image') {
|
|
2299
|
+
const path = slidePath(slides, op.slide);
|
|
2300
|
+
const current = await zipText(zip, path);
|
|
2301
|
+
const tree = containerInner(current, 'p:spTree');
|
|
2302
|
+
if (!tree) throw new Error('PPTX slide shape tree is missing');
|
|
2303
|
+
const shapes = topLevelElements(tree.inner, ['p:sp', 'p:pic', 'p:graphicFrame', 'p:grpSp']);
|
|
2304
|
+
const shape = shapes[Number(op.shape) - 1];
|
|
2305
|
+
if (!shape) throw new Error(`PPTX shape ${op.shape} not found on slide ${op.slide}`);
|
|
2306
|
+
let updated;
|
|
2307
|
+
let detail = {};
|
|
2308
|
+
if (op.op === 'set_table_data') {
|
|
2309
|
+
const values = Array.isArray(op.values) ? op.values.filter((row) => Array.isArray(row)) : [];
|
|
2310
|
+
if (!values.length) throw new Error('set_table_data requires values as an array of rows');
|
|
2311
|
+
const filled = setTableValues(shape.xml, values);
|
|
2312
|
+
updated = filled.xml;
|
|
2313
|
+
detail = { rows: filled.rows, cells: filled.cells, capacity: filled.capacity };
|
|
2314
|
+
if (values.length > filled.capacity) {
|
|
2315
|
+
detail.droppedRows = values.length - filled.capacity;
|
|
2316
|
+
}
|
|
2317
|
+
} else {
|
|
2318
|
+
if (shape.name !== 'p:pic') throw new Error(`PPTX shape ${op.shape} on slide ${op.slide} is not a picture`);
|
|
2319
|
+
const media = await addSlideImage(zip, path, op.path);
|
|
2320
|
+
const embed = /<a:blip\b[^>]*\br:embed="[^"]*"/.exec(shape.xml);
|
|
2321
|
+
if (!embed) throw new Error(`PPTX picture ${op.shape} on slide ${op.slide} has no image reference`);
|
|
2322
|
+
updated = shape.xml.replace(/(<a:blip\b[^>]*\br:embed=")[^"]*(")/, `$1${media.relationshipId}$2`);
|
|
2323
|
+
detail = { image: media.part };
|
|
2324
|
+
}
|
|
2325
|
+
const nextInner = `${tree.inner.slice(0, shape.start)}${updated}${tree.inner.slice(shape.end)}`;
|
|
2326
|
+
zip.file(path, `${current.slice(0, tree.start)}${nextInner}${current.slice(tree.end)}`);
|
|
2327
|
+
results.push({ op: op.op, changed: updated !== shape.xml, slide: Number(op.slide), ...detail });
|
|
2328
|
+
continue;
|
|
2329
|
+
}
|
|
2330
|
+
if (op.op === 'set_shape') {
|
|
2331
|
+
const path = slidePath(slides, op.slide);
|
|
2332
|
+
const current = await zipText(zip, path);
|
|
2333
|
+
const tree = containerInner(current, 'p:spTree');
|
|
2334
|
+
if (!tree) throw new Error('PPTX slide shape tree is missing');
|
|
2335
|
+
const shapes = topLevelElements(tree.inner, ['p:sp', 'p:pic', 'p:graphicFrame', 'p:grpSp']);
|
|
2336
|
+
const shape = shapes[Number(op.shape) - 1];
|
|
2337
|
+
if (!shape) throw new Error(`PPTX shape ${op.shape} not found on slide ${op.slide}`);
|
|
2338
|
+
const updated = updateShapeGeometry(shape.xml, op.properties || {});
|
|
2339
|
+
const nextInner = `${tree.inner.slice(0, shape.start)}${updated}${tree.inner.slice(shape.end)}`;
|
|
2340
|
+
zip.file(path, `${current.slice(0, tree.start)}${nextInner}${current.slice(tree.end)}`);
|
|
2341
|
+
results.push({ op: op.op, changed: updated !== shape.xml });
|
|
2342
|
+
continue;
|
|
2343
|
+
}
|
|
1301
2344
|
throw new Error(`Portable PPTX backend does not support operation: ${op.op}`);
|
|
1302
2345
|
}
|
|
1303
2346
|
return results;
|