@cyberskill/design 1.6.1 → 1.7.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/DESIGN.md +1 -1
- package/README.md +4 -4
- package/SKILL.md +3 -3
- package/VERSION +1 -1
- package/_ds_bundle.js +2 -2
- package/_esm/cs.mjs +2 -2
- package/docs/contrast-report.md +1 -1
- package/docs/conventions.md +2 -2
- package/docs/quality-gates.md +1 -1
- package/docs/release-notes.md +1 -1
- package/docs/vi/contrast-report.md +1 -1
- package/docs/vi/conventions.md +2 -2
- package/docs/vi/quality-gates.md +1 -1
- package/docs/vi/release-notes.md +1 -1
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/templates/_vendor/doc-export.js +570 -110
- package/templates/vn-framework-agreement/VnFrameworkAgreement.dc.html +1 -3
- package/tokens/element-seeds.json +1 -1
- package/tokens/native/CSTokens.kt +1 -1
- package/tokens/native/CSTokens.swift +1 -1
- package/tokens/native/cs_tokens.dart +1 -1
- package/tokens/provenance.json +6 -6
- package/tokens/tokens.dtcg.json +1 -1
- package/tokens/tokens.js +2 -2
- package/tokens/tokens.json +1 -1
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CyberSkill document export —
|
|
2
|
+
* CyberSkill document export — one Download menu (PDF | DOCX).
|
|
3
|
+
* PDF/DOCX are built from the live `.cs-sheet` so attachments match the HTML preview.
|
|
3
4
|
* Loaded by ds-base.js when <meta name="omelette-owns-print"> is present.
|
|
4
5
|
* No CDN; ZIP via CompressionStream (deflate-raw).
|
|
5
6
|
*/
|
|
6
7
|
(() => {
|
|
7
8
|
const UMBER = '45210E';
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
9
|
+
const DOC_FILL = 'FDE68A';
|
|
10
|
+
const A4 = { w: 11906, h: 16838, name: 'A4', ptW: 595.28, ptH: 841.89 };
|
|
11
|
+
const LETTER = { w: 12240, h: 15840, name: 'Letter', ptW: 612, ptH: 792 };
|
|
11
12
|
function pageSizeFromCss() {
|
|
12
13
|
const styles = [...document.querySelectorAll('style')].map((s) => s.textContent || '').join('\n');
|
|
13
14
|
if (/@page\s*\{[^}]*size:\s*letter/i.test(styles)) return LETTER;
|
|
@@ -33,7 +34,6 @@
|
|
|
33
34
|
|
|
34
35
|
async function deflateRaw(u8) {
|
|
35
36
|
if (typeof CompressionStream === 'undefined') {
|
|
36
|
-
// Store uncompressed (ZIP method 0) — still a valid OOXML package.
|
|
37
37
|
return { data: u8, method: 0 };
|
|
38
38
|
}
|
|
39
39
|
const cs = new CompressionStream('deflate-raw');
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
async function buildZip(files) {
|
|
48
|
-
// files: [{ name, bytes: Uint8Array }]
|
|
49
48
|
const enc = new TextEncoder();
|
|
50
49
|
const locals = [];
|
|
51
50
|
const centrals = [];
|
|
@@ -106,8 +105,7 @@
|
|
|
106
105
|
ev.setUint32(16, offset, true);
|
|
107
106
|
ev.setUint16(20, 0, true);
|
|
108
107
|
|
|
109
|
-
const total =
|
|
110
|
-
locals.reduce((n, b) => n + b.length, 0) + centralSize + end.length;
|
|
108
|
+
const total = locals.reduce((n, b) => n + b.length, 0) + centralSize + end.length;
|
|
111
109
|
const out = new Uint8Array(total);
|
|
112
110
|
let o = 0;
|
|
113
111
|
for (const b of locals) {
|
|
@@ -122,21 +120,33 @@
|
|
|
122
120
|
return out;
|
|
123
121
|
}
|
|
124
122
|
|
|
123
|
+
function isChrome(el) {
|
|
124
|
+
return el.getAttribute && el.getAttribute('data-omelette-chrome') != null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isDocFillColor(bg) {
|
|
128
|
+
return bg && /rgb\(\s*253\s*,\s*230\s*,\s*138\s*\)/i.test(bg);
|
|
129
|
+
}
|
|
130
|
+
|
|
125
131
|
function textRuns(el) {
|
|
126
132
|
const parts = [];
|
|
127
|
-
const walk = (node, italic, bold, color) => {
|
|
133
|
+
const walk = (node, italic, bold, color, fill) => {
|
|
128
134
|
if (node.nodeType === 3) {
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
135
|
+
const raw = node.textContent || '';
|
|
136
|
+
if (!raw) return;
|
|
137
|
+
if (!raw.replace(/\s+/g, '').length) {
|
|
138
|
+
if (parts.length) parts.push({ t: ' ', italic, bold, color, fill });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
parts.push({ t: raw.replace(/\s+/g, ' '), italic, bold, color, fill });
|
|
131
142
|
return;
|
|
132
143
|
}
|
|
133
144
|
if (node.nodeType !== 1) return;
|
|
134
|
-
if (node
|
|
145
|
+
if (isChrome(node)) return;
|
|
135
146
|
const tag = node.tagName.toLowerCase();
|
|
136
147
|
if (tag === 'script' || tag === 'style' || tag === 'noscript') return;
|
|
137
148
|
const st = window.getComputedStyle(node);
|
|
138
|
-
const nextItalic =
|
|
139
|
-
italic || st.fontStyle === 'italic' || tag === 'i' || tag === 'em';
|
|
149
|
+
const nextItalic = italic || st.fontStyle === 'italic' || tag === 'i' || tag === 'em';
|
|
140
150
|
const nextBold =
|
|
141
151
|
bold ||
|
|
142
152
|
Number(st.fontWeight) >= 600 ||
|
|
@@ -147,10 +157,38 @@
|
|
|
147
157
|
if (/^h[1-6]$/.test(tag) || (st.color && /rgb\(\s*69\s*,\s*33\s*,\s*14\s*\)/.test(st.color))) {
|
|
148
158
|
nextColor = UMBER;
|
|
149
159
|
}
|
|
150
|
-
|
|
160
|
+
const nextFill = fill || (isDocFillColor(st.backgroundColor) ? DOC_FILL : null);
|
|
161
|
+
const kids = [...node.childNodes];
|
|
162
|
+
for (let i = 0; i < kids.length; i++) {
|
|
163
|
+
const before = parts.length;
|
|
164
|
+
walk(kids[i], nextItalic, nextBold, nextColor, nextFill);
|
|
165
|
+
const nxt = kids[i + 1];
|
|
166
|
+
if (parts.length > before && nxt) {
|
|
167
|
+
const last = parts[parts.length - 1];
|
|
168
|
+
const nextRaw = nxt.textContent || '';
|
|
169
|
+
if (last && last.t && !/\s$/.test(last.t) && nextRaw.replace(/\s+/g, '') && !/^\s/.test(nextRaw)) {
|
|
170
|
+
parts.push({ t: ' ', italic: nextItalic, bold: nextBold, color: nextColor, fill: nextFill });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
151
174
|
};
|
|
152
|
-
walk(el, false, false, null);
|
|
153
|
-
|
|
175
|
+
walk(el, false, false, null, null);
|
|
176
|
+
const merged = [];
|
|
177
|
+
for (const r of parts) {
|
|
178
|
+
const prev = merged[merged.length - 1];
|
|
179
|
+
if (
|
|
180
|
+
prev &&
|
|
181
|
+
prev.italic === r.italic &&
|
|
182
|
+
prev.bold === r.bold &&
|
|
183
|
+
prev.color === r.color &&
|
|
184
|
+
prev.fill === r.fill
|
|
185
|
+
) {
|
|
186
|
+
prev.t += r.t;
|
|
187
|
+
} else {
|
|
188
|
+
merged.push({ ...r });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return merged;
|
|
154
192
|
}
|
|
155
193
|
|
|
156
194
|
function runsToXml(runs) {
|
|
@@ -163,6 +201,7 @@
|
|
|
163
201
|
if (r.bold) rPr.push('<w:b/>');
|
|
164
202
|
if (r.italic) rPr.push('<w:i/>');
|
|
165
203
|
if (r.color) rPr.push(`<w:color w:val="${r.color}"/>`);
|
|
204
|
+
if (r.fill) rPr.push(`<w:shd w:val="clear" w:fill="${r.fill}"/><w:highlight w:val="yellow"/>`);
|
|
166
205
|
rPr.push('<w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:cs="Arial"/>');
|
|
167
206
|
const pr = rPr.length ? `<w:rPr>${rPr.join('')}</w:rPr>` : '';
|
|
168
207
|
return `<w:r>${pr}<w:t xml:space="preserve">${xmlEscape(r.t)}</w:t></w:r>`;
|
|
@@ -170,18 +209,55 @@
|
|
|
170
209
|
.join('');
|
|
171
210
|
}
|
|
172
211
|
|
|
212
|
+
function pPrXml(el, style) {
|
|
213
|
+
const bits = [];
|
|
214
|
+
if (style) bits.push(`<w:pStyle w:val="${style}"/>`);
|
|
215
|
+
if (el) {
|
|
216
|
+
const ta = window.getComputedStyle(el).textAlign;
|
|
217
|
+
if (ta === 'center') bits.push('<w:jc w:val="center"/>');
|
|
218
|
+
else if (ta === 'right' || ta === 'end') bits.push('<w:jc w:val="right"/>');
|
|
219
|
+
}
|
|
220
|
+
return bits.length ? `<w:pPr>${bits.join('')}</w:pPr>` : '';
|
|
221
|
+
}
|
|
222
|
+
|
|
173
223
|
function paraXml(el, style) {
|
|
174
224
|
const runs = textRuns(el);
|
|
175
225
|
if (!runs.length && !el.querySelector?.('br')) return '';
|
|
176
|
-
|
|
177
|
-
|
|
226
|
+
return `<w:p>${pPrXml(el, style)}${runsToXml(runs)}</w:p>`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function cellInnerXml(cell, fillWhite) {
|
|
230
|
+
const blocks = [...cell.children].filter((c) =>
|
|
231
|
+
/^(DIV|P|UL|OL|H1|H2|H3|H4|H5|H6)$/.test(c.tagName),
|
|
232
|
+
);
|
|
233
|
+
const paint = (runs) =>
|
|
234
|
+
runs.map((r) => (fillWhite ? { ...r, color: r.color || 'FFFFFF' } : r));
|
|
235
|
+
if (blocks.length && blocks.length === cell.children.length) {
|
|
236
|
+
const paras = blocks
|
|
237
|
+
.map((b) => {
|
|
238
|
+
const runs = paint(textRuns(b));
|
|
239
|
+
return runs.length ? `<w:p>${runsToXml(runs)}</w:p>` : '';
|
|
240
|
+
})
|
|
241
|
+
.filter(Boolean);
|
|
242
|
+
return paras.join('') || `<w:p>${runsToXml([])}</w:p>`;
|
|
243
|
+
}
|
|
244
|
+
return `<w:p>${runsToXml(paint(textRuns(cell)))}</w:p>`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function cellColSpan(cell) {
|
|
248
|
+
return Math.max(1, Number(cell.getAttribute('colspan')) || 1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function rowColCount(tr) {
|
|
252
|
+
return [...tr.children].reduce((n, c) => n + cellColSpan(c), 0);
|
|
178
253
|
}
|
|
179
254
|
|
|
180
255
|
function tableXml(table) {
|
|
181
256
|
const rows = [...table.querySelectorAll(':scope > tbody > tr, :scope > thead > tr, :scope > tr')];
|
|
182
257
|
if (!rows.length) return '';
|
|
183
|
-
const gridCols = Math.max(...rows.map(
|
|
258
|
+
const gridCols = Math.max(...rows.map(rowColCount), 1);
|
|
184
259
|
const colW = Math.floor(9000 / gridCols);
|
|
260
|
+
const gridColsXml = Array.from({ length: gridCols }, () => `<w:gridCol w:w="${colW}"/>`).join('');
|
|
185
261
|
let xml = `<w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/><w:tblBorders>
|
|
186
262
|
<w:top w:val="single" w:sz="4" w:color="C9B8A8"/>
|
|
187
263
|
<w:left w:val="single" w:sz="4" w:color="C9B8A8"/>
|
|
@@ -189,20 +265,53 @@
|
|
|
189
265
|
<w:right w:val="single" w:sz="4" w:color="C9B8A8"/>
|
|
190
266
|
<w:insideH w:val="single" w:sz="4" w:color="C9B8A8"/>
|
|
191
267
|
<w:insideV w:val="single" w:sz="4" w:color="C9B8A8"/>
|
|
192
|
-
</w:tblBorders></w:tblPr><w:tblGrid>${
|
|
268
|
+
</w:tblBorders></w:tblPr><w:tblGrid>${gridColsXml}</w:tblGrid>`;
|
|
193
269
|
for (const tr of rows) {
|
|
194
270
|
xml += '<w:tr>';
|
|
195
271
|
for (const cell of tr.children) {
|
|
196
272
|
const bg = window.getComputedStyle(cell).backgroundColor;
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
273
|
+
const umberFill = bg && /rgb\(\s*69\s*,\s*33\s*,\s*14\s*\)/.test(bg);
|
|
274
|
+
const yellowFill = isDocFillColor(bg);
|
|
275
|
+
const span = cellColSpan(cell);
|
|
276
|
+
const spanXml = span > 1 ? `<w:gridSpan w:val="${span}"/>` : '';
|
|
277
|
+
const fillXml = umberFill
|
|
278
|
+
? `<w:shd w:val="clear" w:fill="${UMBER}"/>`
|
|
279
|
+
: yellowFill
|
|
280
|
+
? `<w:shd w:val="clear" w:fill="${DOC_FILL}"/>`
|
|
281
|
+
: '';
|
|
282
|
+
xml += `<w:tc><w:tcPr>${fillXml}${spanXml}<w:tcW w:w="${colW * span}" w:type="dxa"/></w:tcPr>${cellInnerXml(cell, umberFill)}</w:tc>`;
|
|
283
|
+
}
|
|
284
|
+
xml += '</w:tr>';
|
|
285
|
+
}
|
|
286
|
+
xml += '</w:tbl>';
|
|
287
|
+
return xml;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function gridColCount(el) {
|
|
291
|
+
const st = window.getComputedStyle(el);
|
|
292
|
+
if (st.display !== 'grid' && st.display !== 'inline-grid') return 0;
|
|
293
|
+
const tpl = st.gridTemplateColumns || '';
|
|
294
|
+
const parts = tpl.split(/\s+/).filter(Boolean);
|
|
295
|
+
return parts.length >= 2 ? parts.length : 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function gridAsTable(el) {
|
|
299
|
+
const cols = gridColCount(el);
|
|
300
|
+
if (!cols) return '';
|
|
301
|
+
const kids = [...el.children].filter((c) => c.nodeType === 1 && !isChrome(c));
|
|
302
|
+
if (kids.length < 2) return '';
|
|
303
|
+
const colW = Math.floor(9000 / cols);
|
|
304
|
+
const gridColsXml = Array.from({ length: cols }, () => `<w:gridCol w:w="${colW}"/>`).join('');
|
|
305
|
+
let xml = `<w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/><w:tblBorders>
|
|
306
|
+
<w:top w:val="nil"/><w:left w:val="nil"/><w:bottom w:val="nil"/><w:right w:val="nil"/>
|
|
307
|
+
<w:insideH w:val="nil"/><w:insideV w:val="nil"/>
|
|
308
|
+
</w:tblBorders></w:tblPr><w:tblGrid>${gridColsXml}</w:tblGrid>`;
|
|
309
|
+
for (let i = 0; i < kids.length; i += cols) {
|
|
310
|
+
xml += '<w:tr>';
|
|
311
|
+
for (let c = 0; c < cols; c++) {
|
|
312
|
+
const src = kids[i + c];
|
|
313
|
+
const inner = src ? cellInnerXml(src, false) : `<w:p>${runsToXml([])}</w:p>`;
|
|
314
|
+
xml += `<w:tc><w:tcPr><w:tcW w:w="${colW}" w:type="dxa"/></w:tcPr>${inner}</w:tc>`;
|
|
206
315
|
}
|
|
207
316
|
xml += '</w:tr>';
|
|
208
317
|
}
|
|
@@ -212,22 +321,25 @@
|
|
|
212
321
|
|
|
213
322
|
function sheetBodyXml(sheet) {
|
|
214
323
|
const blocks = [];
|
|
215
|
-
const kids = [...sheet.children];
|
|
216
324
|
const visit = (nodes) => {
|
|
217
325
|
for (const el of nodes) {
|
|
218
326
|
if (el.nodeType !== 1) continue;
|
|
219
|
-
if (el
|
|
327
|
+
if (isChrome(el)) continue;
|
|
220
328
|
const tag = el.tagName.toLowerCase();
|
|
221
329
|
if (tag === 'table') {
|
|
222
330
|
blocks.push(tableXml(el));
|
|
223
331
|
continue;
|
|
224
332
|
}
|
|
225
333
|
if (/^h[1-6]$/.test(tag)) {
|
|
226
|
-
blocks.push(paraXml(el, 'Heading1'));
|
|
334
|
+
blocks.push(paraXml(el, tag === 'h1' ? 'Heading1' : 'Heading2'));
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const gridXml = gridAsTable(el);
|
|
338
|
+
if (gridXml) {
|
|
339
|
+
blocks.push(gridXml);
|
|
227
340
|
continue;
|
|
228
341
|
}
|
|
229
342
|
if (tag === 'p' || tag === 'div' || tag === 'section' || tag === 'li') {
|
|
230
|
-
// Prefer leaf paragraphs; if a div only wraps structure, descend.
|
|
231
343
|
const hasBlock = [...el.children].some((c) =>
|
|
232
344
|
/^(DIV|TABLE|SECTION|UL|OL|H1|H2|H3|H4|H5|H6)$/.test(c.tagName),
|
|
233
345
|
);
|
|
@@ -246,21 +358,20 @@
|
|
|
246
358
|
visit([...el.children]);
|
|
247
359
|
}
|
|
248
360
|
};
|
|
249
|
-
visit(
|
|
361
|
+
visit([...sheet.children]);
|
|
250
362
|
return blocks.filter(Boolean).join('');
|
|
251
363
|
}
|
|
252
364
|
|
|
253
|
-
function headerFooterText(
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return { header: header.slice(0, 200) || 'CyberSkill', footer };
|
|
365
|
+
function headerFooterText() {
|
|
366
|
+
// Sheet already carries lockup + footer. Word chrome stays empty so the
|
|
367
|
+
// file does not duplicate the card into header/footer.
|
|
368
|
+
return { header: '', footer: '' };
|
|
258
369
|
}
|
|
259
370
|
|
|
260
371
|
async function buildDocxBlob(sheet) {
|
|
261
372
|
const page = pageSizeFromCss();
|
|
262
373
|
const enc = new TextEncoder();
|
|
263
|
-
const { header, footer } = headerFooterText(
|
|
374
|
+
const { header, footer } = headerFooterText();
|
|
264
375
|
const body = sheetBodyXml(sheet);
|
|
265
376
|
|
|
266
377
|
const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
@@ -295,26 +406,20 @@
|
|
|
295
406
|
<w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:qFormat/>
|
|
296
407
|
<w:rPr><w:b/><w:color w:val="${UMBER}"/><w:sz w:val="28"/></w:rPr>
|
|
297
408
|
</w:style>
|
|
409
|
+
<w:style w:type="paragraph" w:styleId="Heading2">
|
|
410
|
+
<w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:qFormat/>
|
|
411
|
+
<w:rPr><w:b/><w:color w:val="${UMBER}"/><w:sz w:val="24"/></w:rPr>
|
|
412
|
+
</w:style>
|
|
298
413
|
</w:styles>`;
|
|
299
414
|
|
|
300
415
|
const headerXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
301
416
|
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
302
|
-
<w:p
|
|
303
|
-
<w:r><w:rPr><w:b/><w:color w:val="${UMBER}"/><w:sz w:val="18"/></w:rPr>
|
|
304
|
-
<w:t xml:space="preserve">${xmlEscape(header)}</w:t></w:r>
|
|
305
|
-
</w:p>
|
|
417
|
+
<w:p>${header ? `<w:r><w:t xml:space="preserve">${xmlEscape(header)}</w:t></w:r>` : ''}</w:p>
|
|
306
418
|
</w:hdr>`;
|
|
307
419
|
|
|
308
420
|
const footerXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
309
|
-
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
310
|
-
|
|
311
|
-
<w:p>
|
|
312
|
-
<w:r><w:rPr><w:sz w:val="16"/><w:color w:val="6B5D4F"/></w:rPr>
|
|
313
|
-
<w:t xml:space="preserve">${xmlEscape(footer)} · </w:t></w:r>
|
|
314
|
-
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
|
|
315
|
-
<w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
|
|
316
|
-
<w:r><w:fldChar w:fldCharType="end"/></w:r>
|
|
317
|
-
</w:p>
|
|
421
|
+
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
422
|
+
<w:p>${footer ? `<w:r><w:t xml:space="preserve">${xmlEscape(footer)}</w:t></w:r>` : ''}</w:p>
|
|
318
423
|
</w:ftr>`;
|
|
319
424
|
|
|
320
425
|
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
@@ -345,17 +450,336 @@
|
|
|
345
450
|
});
|
|
346
451
|
}
|
|
347
452
|
|
|
348
|
-
function
|
|
453
|
+
function cssVarBlock() {
|
|
454
|
+
const cs = getComputedStyle(document.documentElement);
|
|
455
|
+
const parts = [];
|
|
456
|
+
for (let i = 0; i < cs.length; i++) {
|
|
457
|
+
const p = cs[i];
|
|
458
|
+
if (p.startsWith('--')) parts.push(`${p}:${cs.getPropertyValue(p)}`);
|
|
459
|
+
}
|
|
460
|
+
return parts.length ? `:root{${parts.join(';')}}` : '';
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function dataUrlToBytes(dataUrl) {
|
|
464
|
+
const b64 = dataUrl.split(',')[1] || '';
|
|
465
|
+
const bin = atob(b64);
|
|
466
|
+
const out = new Uint8Array(bin.length);
|
|
467
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function canvasHasInk(ctx, w, h) {
|
|
472
|
+
const stepX = Math.max(1, Math.floor(w / 12));
|
|
473
|
+
const stepY = Math.max(1, Math.floor(h / 16));
|
|
474
|
+
for (let y = 8; y < h; y += stepY) {
|
|
475
|
+
for (let x = 8; x < w; x += stepX) {
|
|
476
|
+
const p = ctx.getImageData(x, y, 1, 1).data;
|
|
477
|
+
if (p[0] < 248 || p[1] < 248 || p[2] < 248) return true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function loadImage(url) {
|
|
484
|
+
return new Promise((resolve, reject) => {
|
|
485
|
+
const img = new Image();
|
|
486
|
+
img.onload = () => resolve(img);
|
|
487
|
+
img.onerror = () => reject(new Error('sheet-image-failed'));
|
|
488
|
+
img.src = url;
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function concatBytes(parts) {
|
|
493
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
494
|
+
const out = new Uint8Array(total);
|
|
495
|
+
let o = 0;
|
|
496
|
+
for (const p of parts) {
|
|
497
|
+
out.set(p, o);
|
|
498
|
+
o += p.length;
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function jpegPagesToPdf(pages, mediaW, mediaH) {
|
|
504
|
+
const enc = new TextEncoder();
|
|
505
|
+
const nl = enc.encode('\n');
|
|
506
|
+
const chunks = [enc.encode('%PDF-1.4\n')];
|
|
507
|
+
const offsets = [0];
|
|
508
|
+
const pushObj = (id, dict, stream) => {
|
|
509
|
+
offsets[id] = chunks.reduce((n, c) => n + c.length, 0);
|
|
510
|
+
chunks.push(enc.encode(`${id} 0 obj\n`));
|
|
511
|
+
if (stream) {
|
|
512
|
+
chunks.push(enc.encode(dict));
|
|
513
|
+
chunks.push(nl);
|
|
514
|
+
chunks.push(enc.encode('stream\n'));
|
|
515
|
+
chunks.push(stream);
|
|
516
|
+
chunks.push(enc.encode('\nendstream\nendobj\n'));
|
|
517
|
+
} else {
|
|
518
|
+
chunks.push(enc.encode(dict));
|
|
519
|
+
chunks.push(enc.encode('\nendobj\n'));
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const pageIds = [];
|
|
524
|
+
let nextId = 3;
|
|
525
|
+
for (const page of pages) {
|
|
526
|
+
const pageId = nextId++;
|
|
527
|
+
const imgId = nextId++;
|
|
528
|
+
const contentId = nextId++;
|
|
529
|
+
pageIds.push(pageId);
|
|
530
|
+
const content = `q ${mediaW.toFixed(2)} 0 0 ${mediaH.toFixed(2)} 0 0 cm /Im0 Do Q\n`;
|
|
531
|
+
pushObj(
|
|
532
|
+
pageId,
|
|
533
|
+
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${mediaW.toFixed(2)} ${mediaH.toFixed(2)}] /Resources << /XObject << /Im0 ${imgId} 0 R >> >> /Contents ${contentId} 0 R >>`,
|
|
534
|
+
);
|
|
535
|
+
pushObj(
|
|
536
|
+
imgId,
|
|
537
|
+
`<< /Type /XObject /Subtype /Image /Width ${page.w} /Height ${page.h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${page.jpeg.length} >>`,
|
|
538
|
+
page.jpeg,
|
|
539
|
+
);
|
|
540
|
+
const contentBytes = enc.encode(content);
|
|
541
|
+
pushObj(contentId, `<< /Length ${contentBytes.length} >>`, contentBytes);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
pushObj(1, '<< /Type /Catalog /Pages 2 0 R >>');
|
|
545
|
+
pushObj(
|
|
546
|
+
2,
|
|
547
|
+
`<< /Type /Pages /Kids [${pageIds.map((id) => id + ' 0 R').join(' ')}] /Count ${pageIds.length} >>`,
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
const body = concatBytes(chunks);
|
|
551
|
+
const xrefAt = body.length;
|
|
552
|
+
let xref = `xref\n0 ${nextId}\n0000000000 65535 f \n`;
|
|
553
|
+
for (let i = 1; i < nextId; i++) {
|
|
554
|
+
xref += String(offsets[i] || 0).padStart(10, '0') + ' 00000 n \n';
|
|
555
|
+
}
|
|
556
|
+
const trailer = `trailer\n<< /Size ${nextId} /Root 1 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
|
|
557
|
+
return concatBytes([body, enc.encode(xref), enc.encode(trailer)]);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function relBox(el, origin, sheet) {
|
|
561
|
+
const r = el.getBoundingClientRect();
|
|
562
|
+
return {
|
|
563
|
+
x: r.left - origin.left + sheet.scrollLeft,
|
|
564
|
+
y: r.top - origin.top + sheet.scrollTop,
|
|
565
|
+
w: r.width,
|
|
566
|
+
h: r.height,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function paintBox(ctx, el, origin, sheet) {
|
|
571
|
+
const cs = getComputedStyle(el);
|
|
572
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return;
|
|
573
|
+
const b = relBox(el, origin, sheet);
|
|
574
|
+
const bg = cs.backgroundColor;
|
|
575
|
+
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
|
|
576
|
+
ctx.fillStyle = bg;
|
|
577
|
+
ctx.fillRect(b.x, b.y, b.w, b.h);
|
|
578
|
+
}
|
|
579
|
+
const sides = [
|
|
580
|
+
['Top', b.x, b.y, b.x + b.w, b.y],
|
|
581
|
+
['Right', b.x + b.w, b.y, b.x + b.w, b.y + b.h],
|
|
582
|
+
['Bottom', b.x, b.y + b.h, b.x + b.w, b.y + b.h],
|
|
583
|
+
['Left', b.x, b.y, b.x, b.y + b.h],
|
|
584
|
+
];
|
|
585
|
+
for (const [side, x1, y1, x2, y2] of sides) {
|
|
586
|
+
const w = parseFloat(cs[`border${side}Width`]) || 0;
|
|
587
|
+
if (w <= 0 || cs[`border${side}Style`] === 'none') continue;
|
|
588
|
+
ctx.strokeStyle = cs[`border${side}Color`];
|
|
589
|
+
ctx.lineWidth = w;
|
|
590
|
+
ctx.beginPath();
|
|
591
|
+
ctx.moveTo(x1, y1);
|
|
592
|
+
ctx.lineTo(x2, y2);
|
|
593
|
+
ctx.stroke();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function paintTextNode(ctx, node, origin, sheet) {
|
|
598
|
+
const raw = node.textContent || '';
|
|
599
|
+
if (!raw.replace(/\s+/g, '').length) return;
|
|
600
|
+
const parent = node.parentElement;
|
|
601
|
+
if (!parent) return;
|
|
602
|
+
const cs = getComputedStyle(parent);
|
|
603
|
+
if (cs.display === 'none' || cs.visibility === 'hidden') return;
|
|
604
|
+
ctx.save();
|
|
605
|
+
ctx.fillStyle = cs.color || '#000';
|
|
606
|
+
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
|
607
|
+
ctx.textBaseline = 'top';
|
|
608
|
+
const range = document.createRange();
|
|
609
|
+
const parts = raw.split(/(\s+)/);
|
|
610
|
+
let offset = 0;
|
|
611
|
+
for (const part of parts) {
|
|
612
|
+
const next = offset + part.length;
|
|
613
|
+
if (part.replace(/\s+/g, '').length) {
|
|
614
|
+
try {
|
|
615
|
+
range.setStart(node, offset);
|
|
616
|
+
range.setEnd(node, Math.min(next, raw.length));
|
|
617
|
+
const r = range.getBoundingClientRect();
|
|
618
|
+
if (r.width || r.height) {
|
|
619
|
+
ctx.fillText(
|
|
620
|
+
part,
|
|
621
|
+
r.left - origin.left + sheet.scrollLeft,
|
|
622
|
+
r.top - origin.top + sheet.scrollTop,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
} catch (_) {
|
|
626
|
+
/* invalid offsets on some hosts */
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
offset = next;
|
|
630
|
+
}
|
|
631
|
+
ctx.restore();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function paintSvg(ctx, svg, origin, sheet) {
|
|
635
|
+
const b = relBox(svg, origin, sheet);
|
|
636
|
+
if (b.w < 1 || b.h < 1) return;
|
|
637
|
+
try {
|
|
638
|
+
const xml = new XMLSerializer().serializeToString(svg);
|
|
639
|
+
if (/foreignObject/i.test(xml)) return;
|
|
640
|
+
const img = await loadImage('data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml));
|
|
641
|
+
ctx.drawImage(img, b.x, b.y, b.w, b.h);
|
|
642
|
+
} catch (_) {
|
|
643
|
+
/* skip logo if SVG paint fails */
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function paintSheet(sheet, scale) {
|
|
648
|
+
const origin = sheet.getBoundingClientRect();
|
|
649
|
+
const width = Math.max(sheet.scrollWidth, sheet.clientWidth, 1);
|
|
650
|
+
const height = Math.max(sheet.scrollHeight, sheet.clientHeight, 1);
|
|
651
|
+
const canvas = document.createElement('canvas');
|
|
652
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
653
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
654
|
+
const ctx = canvas.getContext('2d');
|
|
655
|
+
ctx.scale(scale, scale);
|
|
656
|
+
ctx.fillStyle = '#ffffff';
|
|
657
|
+
ctx.fillRect(0, 0, width, height);
|
|
658
|
+
const svgs = [];
|
|
659
|
+
const walk = (node) => {
|
|
660
|
+
if (node.nodeType === 1) {
|
|
661
|
+
if (isChrome(node)) return;
|
|
662
|
+
const tag = node.tagName.toLowerCase();
|
|
663
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript') return;
|
|
664
|
+
if (tag === 'svg') {
|
|
665
|
+
paintBox(ctx, node, origin, sheet);
|
|
666
|
+
svgs.push(node);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
paintBox(ctx, node, origin, sheet);
|
|
670
|
+
for (const c of node.childNodes) walk(c);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (node.nodeType === 3) paintTextNode(ctx, node, origin, sheet);
|
|
674
|
+
};
|
|
675
|
+
walk(sheet);
|
|
676
|
+
for (const svg of svgs) await paintSvg(ctx, svg, origin, sheet);
|
|
677
|
+
return { canvas, width, height, ctx };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function rasterizeSheet(sheet) {
|
|
681
|
+
const page = pageSizeFromCss();
|
|
682
|
+
const scale = 2;
|
|
683
|
+
const painted = await paintSheet(sheet, scale);
|
|
684
|
+
const { canvas, width, height, ctx } = painted;
|
|
685
|
+
let ink = true;
|
|
686
|
+
try {
|
|
687
|
+
ink = canvasHasInk(ctx, canvas.width, Math.min(canvas.height, 400));
|
|
688
|
+
} catch (_) {
|
|
689
|
+
ink = true;
|
|
690
|
+
}
|
|
691
|
+
if (!ink) throw new Error('blank-raster');
|
|
692
|
+
// One PDF page = the whole card. Slicing to A4 cut tables mid-row and
|
|
693
|
+
// stretched the leftover slice to full page height (squashed last page).
|
|
694
|
+
const jpeg = dataUrlToBytes(canvas.toDataURL('image/jpeg', 0.95));
|
|
695
|
+
const ptW = page.ptW;
|
|
696
|
+
const ptH = ptW * (canvas.height / canvas.width);
|
|
697
|
+
return jpegPagesToPdf([{ jpeg, w: canvas.width, h: canvas.height }], ptW, ptH);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function buildPdfBlob(sheet) {
|
|
701
|
+
const bytes = await rasterizeSheet(sheet);
|
|
702
|
+
return new Blob([bytes], { type: 'application/pdf' });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function printPdf() {
|
|
706
|
+
const sheet = document.querySelector('.cs-sheet') || document.body;
|
|
707
|
+
const page = pageSizeFromCss();
|
|
708
|
+
const size = page.name === 'Letter' ? 'letter' : 'A4';
|
|
709
|
+
const clone = sheet.cloneNode(true);
|
|
710
|
+
clone.querySelectorAll('[data-omelette-chrome]').forEach((n) => n.remove());
|
|
711
|
+
const links = [...document.querySelectorAll('link[rel="stylesheet"]')]
|
|
712
|
+
.map((l) => `<link rel="stylesheet" href="${xmlEscape(l.href)}">`)
|
|
713
|
+
.join('');
|
|
714
|
+
const styles = [...document.querySelectorAll('style')]
|
|
715
|
+
.filter((s) => s.getAttribute('data-omelette-chrome') == null)
|
|
716
|
+
.map((s) => `<style>${s.textContent || ''}</style>`)
|
|
717
|
+
.join('');
|
|
718
|
+
const title = xmlEscape(document.title || 'document');
|
|
719
|
+
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${title}</title>
|
|
720
|
+
${links}${styles}
|
|
721
|
+
<style>${cssVarBlock()}
|
|
722
|
+
@page{size:${size};margin:16mm 14mm}
|
|
723
|
+
html,body{margin:0;padding:0;background:#fff!important;height:auto!important;max-height:none!important;overflow:visible!important}
|
|
724
|
+
.cs-desk{background:#fff!important;padding:0!important;min-height:0!important}
|
|
725
|
+
.cs-sheet{box-shadow:none!important;margin:0 auto!important}
|
|
726
|
+
[data-omelette-chrome]{display:none!important}
|
|
727
|
+
</style></head><body>${clone.outerHTML}</body></html>`;
|
|
728
|
+
const url = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=utf-8' }));
|
|
729
|
+
let w = null;
|
|
730
|
+
try {
|
|
731
|
+
w = window.open(url, '_blank');
|
|
732
|
+
} catch (_) {
|
|
733
|
+
w = null;
|
|
734
|
+
}
|
|
735
|
+
if (!w) {
|
|
736
|
+
URL.revokeObjectURL(url);
|
|
737
|
+
window.print();
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
let printed = false;
|
|
741
|
+
const go = () => {
|
|
742
|
+
if (printed) return;
|
|
743
|
+
printed = true;
|
|
744
|
+
try {
|
|
745
|
+
w.focus();
|
|
746
|
+
w.print();
|
|
747
|
+
} catch (_) {
|
|
748
|
+
/* ignore */
|
|
749
|
+
}
|
|
750
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
|
751
|
+
};
|
|
752
|
+
w.addEventListener('load', () => setTimeout(go, 400));
|
|
753
|
+
setTimeout(go, 1600);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function downloadBlob(blob, filename) {
|
|
757
|
+
const type = blob.type || 'application/octet-stream';
|
|
758
|
+
try {
|
|
759
|
+
const file = new File([blob], filename, { type });
|
|
760
|
+
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
|
761
|
+
try {
|
|
762
|
+
await navigator.share({ files: [file], title: filename });
|
|
763
|
+
return;
|
|
764
|
+
} catch (e) {
|
|
765
|
+
if (e && e.name === 'AbortError') return;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
} catch (_) {
|
|
769
|
+
/* File/share unsupported */
|
|
770
|
+
}
|
|
771
|
+
const url = URL.createObjectURL(blob);
|
|
349
772
|
const a = document.createElement('a');
|
|
350
|
-
a.href =
|
|
773
|
+
a.href = url;
|
|
351
774
|
a.download = filename;
|
|
352
775
|
a.rel = 'noopener';
|
|
776
|
+
a.target = '_blank';
|
|
353
777
|
document.body.appendChild(a);
|
|
354
778
|
a.click();
|
|
355
779
|
setTimeout(() => {
|
|
356
|
-
URL.revokeObjectURL(
|
|
780
|
+
URL.revokeObjectURL(url);
|
|
357
781
|
a.remove();
|
|
358
|
-
},
|
|
782
|
+
}, 4000);
|
|
359
783
|
}
|
|
360
784
|
|
|
361
785
|
function suggestName(ext) {
|
|
@@ -363,38 +787,22 @@
|
|
|
363
787
|
return t.replace(/[^\w\u00C0-\u024F\- ]+/g, '').replace(/\s+/g, '-').slice(0, 80) + ext;
|
|
364
788
|
}
|
|
365
789
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
* framed viewport / height:100% root). Prefer a top-level window of the same
|
|
369
|
-
* document URL; fall back to in-frame window.print().
|
|
370
|
-
*/
|
|
371
|
-
function printPdf() {
|
|
790
|
+
async function downloadPdf() {
|
|
791
|
+
const sheet = document.querySelector('.cs-sheet') || document.body;
|
|
372
792
|
try {
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (printed) return;
|
|
380
|
-
printed = true;
|
|
381
|
-
try {
|
|
382
|
-
w.focus();
|
|
383
|
-
w.print();
|
|
384
|
-
} catch (_) {
|
|
385
|
-
/* ignore */
|
|
386
|
-
}
|
|
387
|
-
};
|
|
388
|
-
w.addEventListener('load', () => setTimeout(go, 500));
|
|
389
|
-
// Cached loads may not fire `load` reliably on iOS.
|
|
390
|
-
setTimeout(go, 1800);
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
} catch (_) {
|
|
395
|
-
/* cross-origin top access or popup blocked */
|
|
793
|
+
const blob = await buildPdfBlob(sheet);
|
|
794
|
+
if (!blob || blob.size < 200) throw new Error('empty-pdf');
|
|
795
|
+
await downloadBlob(blob, suggestName('.pdf'));
|
|
796
|
+
} catch (e) {
|
|
797
|
+
console.warn('[doc-export] raster PDF failed, printing sheet clone', e);
|
|
798
|
+
printPdf();
|
|
396
799
|
}
|
|
397
|
-
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function downloadDocx() {
|
|
803
|
+
const sheet = document.querySelector('.cs-sheet') || document.body;
|
|
804
|
+
const blob = await buildDocxBlob(sheet);
|
|
805
|
+
await downloadBlob(blob, suggestName('.docx'));
|
|
398
806
|
}
|
|
399
807
|
|
|
400
808
|
function injectToolbar() {
|
|
@@ -406,7 +814,7 @@
|
|
|
406
814
|
style.setAttribute('data-omelette-chrome', 'export-style');
|
|
407
815
|
style.textContent = `
|
|
408
816
|
[data-omelette-chrome="export"]{
|
|
409
|
-
position:fixed;z-index:2147483000;top:12px;right:12px;display:flex;gap:
|
|
817
|
+
position:fixed;z-index:2147483000;top:12px;right:12px;display:flex;flex-direction:column;align-items:flex-end;gap:4px;
|
|
410
818
|
font-family:var(--cs-font-family-ui,system-ui,sans-serif);pointer-events:auto;
|
|
411
819
|
}
|
|
412
820
|
[data-omelette-chrome="export"] button{
|
|
@@ -416,10 +824,16 @@
|
|
|
416
824
|
}
|
|
417
825
|
[data-omelette-chrome="export"] button:hover{background:#f7f0e6}
|
|
418
826
|
[data-omelette-chrome="export"] button:focus-visible{outline:2px solid #F4BA17;outline-offset:2px}
|
|
827
|
+
[data-cs-export="menu"]{
|
|
828
|
+
min-width:148px;background:#fff;border:1px solid #c9b8a8;border-radius:8px;
|
|
829
|
+
box-shadow:0 8px 24px rgba(35,26,18,.12);overflow:hidden;padding:4px;
|
|
830
|
+
}
|
|
831
|
+
[data-cs-export="menu"][hidden]{display:none!important}
|
|
832
|
+
[data-cs-export="menu"] button{
|
|
833
|
+
display:block;width:100%;border:0;border-radius:6px;box-shadow:none;text-align:left;font-weight:650;
|
|
834
|
+
}
|
|
419
835
|
@media print{
|
|
420
836
|
[data-omelette-chrome="export"],[data-omelette-chrome="export-style"]{display:none!important}
|
|
421
|
-
/* Belt-and-suspenders with support.js print baseline — unlock FULL_PAGE_CSS
|
|
422
|
-
height:100% so Safari/iOS does not print a blank clipped viewport. */
|
|
423
837
|
html,body,#dc-root,#dc-root>.sc-host{
|
|
424
838
|
height:auto!important;min-height:0!important;max-height:none!important;overflow:visible!important
|
|
425
839
|
}
|
|
@@ -429,39 +843,86 @@
|
|
|
429
843
|
|
|
430
844
|
const bar = document.createElement('div');
|
|
431
845
|
bar.setAttribute('data-omelette-chrome', 'export');
|
|
432
|
-
bar.setAttribute('role', '
|
|
433
|
-
bar.setAttribute('aria-label', '
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
846
|
+
bar.setAttribute('role', 'group');
|
|
847
|
+
bar.setAttribute('aria-label', 'Tải tài liệu · Download document');
|
|
848
|
+
|
|
849
|
+
const trigger = document.createElement('button');
|
|
850
|
+
trigger.type = 'button';
|
|
851
|
+
trigger.setAttribute('data-cs-export', 'trigger');
|
|
852
|
+
trigger.setAttribute('aria-haspopup', 'menu');
|
|
853
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
854
|
+
trigger.textContent = 'Tải / Download ▾';
|
|
855
|
+
|
|
856
|
+
const menu = document.createElement('div');
|
|
857
|
+
menu.setAttribute('data-cs-export', 'menu');
|
|
858
|
+
menu.setAttribute('role', 'menu');
|
|
859
|
+
menu.hidden = true;
|
|
860
|
+
|
|
861
|
+
const pdfItem = document.createElement('button');
|
|
862
|
+
pdfItem.type = 'button';
|
|
863
|
+
pdfItem.setAttribute('role', 'menuitem');
|
|
864
|
+
pdfItem.setAttribute('data-cs-export', 'pdf');
|
|
865
|
+
pdfItem.textContent = 'PDF';
|
|
866
|
+
|
|
867
|
+
const docxItem = document.createElement('button');
|
|
868
|
+
docxItem.type = 'button';
|
|
869
|
+
docxItem.setAttribute('role', 'menuitem');
|
|
870
|
+
docxItem.setAttribute('data-cs-export', 'docx');
|
|
871
|
+
docxItem.textContent = 'DOCX';
|
|
872
|
+
|
|
873
|
+
const setOpen = (open) => {
|
|
874
|
+
menu.hidden = !open;
|
|
875
|
+
trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
trigger.addEventListener('click', () => setOpen(menu.hidden));
|
|
879
|
+
trigger.addEventListener('keydown', (e) => {
|
|
880
|
+
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
|
881
|
+
e.preventDefault();
|
|
882
|
+
setOpen(true);
|
|
883
|
+
pdfItem.focus();
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
menu.addEventListener('keydown', (e) => {
|
|
887
|
+
if (e.key === 'Escape') {
|
|
888
|
+
e.preventDefault();
|
|
889
|
+
setOpen(false);
|
|
890
|
+
trigger.focus();
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
document.addEventListener('click', (e) => {
|
|
894
|
+
if (!bar.contains(e.target)) setOpen(false);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
pdfItem.addEventListener('click', async () => {
|
|
898
|
+
setOpen(false);
|
|
899
|
+
await downloadPdf();
|
|
900
|
+
});
|
|
901
|
+
docxItem.addEventListener('click', async () => {
|
|
902
|
+
setOpen(false);
|
|
444
903
|
try {
|
|
445
|
-
|
|
446
|
-
const blob = await buildDocxBlob(sheet);
|
|
447
|
-
downloadBlob(blob, suggestName('.docx'));
|
|
904
|
+
await downloadDocx();
|
|
448
905
|
} catch (e) {
|
|
449
906
|
console.error('[doc-export] DOCX failed', e);
|
|
450
907
|
alert('DOCX export failed: ' + (e && e.message ? e.message : e));
|
|
451
908
|
}
|
|
452
909
|
});
|
|
453
910
|
|
|
454
|
-
|
|
455
|
-
|
|
911
|
+
menu.appendChild(pdfItem);
|
|
912
|
+
menu.appendChild(docxItem);
|
|
913
|
+
bar.appendChild(trigger);
|
|
914
|
+
bar.appendChild(menu);
|
|
456
915
|
document.body.appendChild(bar);
|
|
457
916
|
}
|
|
458
917
|
|
|
459
|
-
// Test / automation hooks
|
|
460
918
|
window.__csDocExport = {
|
|
461
919
|
buildDocxBlob,
|
|
920
|
+
buildPdfBlob,
|
|
462
921
|
pageSizeFromCss,
|
|
463
922
|
injectToolbar,
|
|
464
923
|
printPdf,
|
|
924
|
+
downloadPdf,
|
|
925
|
+
downloadDocx,
|
|
465
926
|
};
|
|
466
927
|
|
|
467
928
|
const boot = () => injectToolbar();
|
|
@@ -470,6 +931,5 @@
|
|
|
470
931
|
} else {
|
|
471
932
|
boot();
|
|
472
933
|
}
|
|
473
|
-
// DC boots async — re-try after paint so .cs-sheet exists for future exports.
|
|
474
934
|
setTimeout(boot, 800);
|
|
475
935
|
})();
|