@cyberskill/design 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,13 @@
1
1
  /**
2
- * CyberSkill document export — PDF (print) + editable OOXML DOCX.
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 A4 = { w: 11906, h: 16838, name: 'A4' }; // twips
9
- const LETTER = { w: 12240, h: 15840, name: 'Letter' };
10
-
9
+ const A4 = { w: 11906, h: 16838, name: 'A4', ptW: 595.28, ptH: 841.89 };
10
+ const LETTER = { w: 12240, h: 15840, name: 'Letter', ptW: 612, ptH: 792 };
11
11
  function pageSizeFromCss() {
12
12
  const styles = [...document.querySelectorAll('style')].map((s) => s.textContent || '').join('\n');
13
13
  if (/@page\s*\{[^}]*size:\s*letter/i.test(styles)) return LETTER;
@@ -33,7 +33,6 @@
33
33
 
34
34
  async function deflateRaw(u8) {
35
35
  if (typeof CompressionStream === 'undefined') {
36
- // Store uncompressed (ZIP method 0) — still a valid OOXML package.
37
36
  return { data: u8, method: 0 };
38
37
  }
39
38
  const cs = new CompressionStream('deflate-raw');
@@ -45,7 +44,6 @@
45
44
  }
46
45
 
47
46
  async function buildZip(files) {
48
- // files: [{ name, bytes: Uint8Array }]
49
47
  const enc = new TextEncoder();
50
48
  const locals = [];
51
49
  const centrals = [];
@@ -106,8 +104,7 @@
106
104
  ev.setUint32(16, offset, true);
107
105
  ev.setUint16(20, 0, true);
108
106
 
109
- const total =
110
- locals.reduce((n, b) => n + b.length, 0) + centralSize + end.length;
107
+ const total = locals.reduce((n, b) => n + b.length, 0) + centralSize + end.length;
111
108
  const out = new Uint8Array(total);
112
109
  let o = 0;
113
110
  for (const b of locals) {
@@ -122,21 +119,29 @@
122
119
  return out;
123
120
  }
124
121
 
122
+ function isChrome(el) {
123
+ return el.getAttribute && el.getAttribute('data-omelette-chrome') != null;
124
+ }
125
+
125
126
  function textRuns(el) {
126
127
  const parts = [];
127
128
  const walk = (node, italic, bold, color) => {
128
129
  if (node.nodeType === 3) {
129
- const t = node.textContent;
130
- if (t && t.replace(/\s+/g, '').length) parts.push({ t, italic, bold, color });
130
+ const raw = node.textContent || '';
131
+ if (!raw) return;
132
+ if (!raw.replace(/\s+/g, '').length) {
133
+ if (parts.length) parts.push({ t: ' ', italic, bold, color });
134
+ return;
135
+ }
136
+ parts.push({ t: raw.replace(/\s+/g, ' '), italic, bold, color });
131
137
  return;
132
138
  }
133
139
  if (node.nodeType !== 1) return;
134
- if (node.getAttribute && node.getAttribute('data-omelette-chrome') != null) return;
140
+ if (isChrome(node)) return;
135
141
  const tag = node.tagName.toLowerCase();
136
142
  if (tag === 'script' || tag === 'style' || tag === 'noscript') return;
137
143
  const st = window.getComputedStyle(node);
138
- const nextItalic =
139
- italic || st.fontStyle === 'italic' || tag === 'i' || tag === 'em';
144
+ const nextItalic = italic || st.fontStyle === 'italic' || tag === 'i' || tag === 'em';
140
145
  const nextBold =
141
146
  bold ||
142
147
  Number(st.fontWeight) >= 600 ||
@@ -150,7 +155,21 @@
150
155
  for (const c of node.childNodes) walk(c, nextItalic, nextBold, nextColor);
151
156
  };
152
157
  walk(el, false, false, null);
153
- return parts;
158
+ const merged = [];
159
+ for (const r of parts) {
160
+ const prev = merged[merged.length - 1];
161
+ if (
162
+ prev &&
163
+ prev.italic === r.italic &&
164
+ prev.bold === r.bold &&
165
+ prev.color === r.color
166
+ ) {
167
+ prev.t += r.t;
168
+ } else {
169
+ merged.push({ ...r });
170
+ }
171
+ }
172
+ return merged;
154
173
  }
155
174
 
156
175
  function runsToXml(runs) {
@@ -177,6 +196,24 @@
177
196
  return `<w:p>${pPr}${runsToXml(runs)}</w:p>`;
178
197
  }
179
198
 
199
+ function cellInnerXml(cell, fillWhite) {
200
+ const blocks = [...cell.children].filter((c) =>
201
+ /^(DIV|P|UL|OL|H1|H2|H3|H4|H5|H6)$/.test(c.tagName),
202
+ );
203
+ const paint = (runs) =>
204
+ runs.map((r) => (fillWhite ? { ...r, color: r.color || 'FFFFFF' } : r));
205
+ if (blocks.length && blocks.length === cell.children.length) {
206
+ const paras = blocks
207
+ .map((b) => {
208
+ const runs = paint(textRuns(b));
209
+ return runs.length ? `<w:p>${runsToXml(runs)}</w:p>` : '';
210
+ })
211
+ .filter(Boolean);
212
+ return paras.join('') || `<w:p>${runsToXml([])}</w:p>`;
213
+ }
214
+ return `<w:p>${runsToXml(paint(textRuns(cell)))}</w:p>`;
215
+ }
216
+
180
217
  function tableXml(table) {
181
218
  const rows = [...table.querySelectorAll(':scope > tbody > tr, :scope > thead > tr, :scope > tr')];
182
219
  if (!rows.length) return '';
@@ -194,15 +231,42 @@
194
231
  xml += '<w:tr>';
195
232
  for (const cell of tr.children) {
196
233
  const bg = window.getComputedStyle(cell).backgroundColor;
197
- const shade =
198
- bg && /rgb\(\s*69\s*,\s*33\s*,\s*14\s*\)/.test(bg)
199
- ? `<w:tcPr><w:shd w:val="clear" w:fill="${UMBER}"/><w:tcW w:w="${colW}" w:type="dxa"/></w:tcPr>`
200
- : `<w:tcPr><w:tcW w:w="${colW}" w:type="dxa"/></w:tcPr>`;
201
- const cellColor = shade.includes(UMBER) ? 'FFFFFF' : null;
202
- const runs = textRuns(cell).map((r) =>
203
- cellColor ? { ...r, color: r.color || cellColor } : r,
204
- );
205
- xml += `<w:tc>${shade}<w:p>${runsToXml(runs)}</w:p></w:tc>`;
234
+ const umberFill = bg && /rgb\(\s*69\s*,\s*33\s*,\s*14\s*\)/.test(bg);
235
+ const shade = umberFill
236
+ ? `<w:tcPr><w:shd w:val="clear" w:fill="${UMBER}"/><w:tcW w:w="${colW}" w:type="dxa"/></w:tcPr>`
237
+ : `<w:tcPr><w:tcW w:w="${colW}" w:type="dxa"/></w:tcPr>`;
238
+ xml += `<w:tc>${shade}${cellInnerXml(cell, umberFill)}</w:tc>`;
239
+ }
240
+ xml += '</w:tr>';
241
+ }
242
+ xml += '</w:tbl>';
243
+ return xml;
244
+ }
245
+
246
+ function gridColCount(el) {
247
+ const st = window.getComputedStyle(el);
248
+ if (st.display !== 'grid' && st.display !== 'inline-grid') return 0;
249
+ const tpl = st.gridTemplateColumns || '';
250
+ const parts = tpl.split(/\s+/).filter(Boolean);
251
+ return parts.length >= 2 ? parts.length : 0;
252
+ }
253
+
254
+ function gridAsTable(el) {
255
+ const cols = gridColCount(el);
256
+ if (!cols) return '';
257
+ const kids = [...el.children].filter((c) => c.nodeType === 1 && !isChrome(c));
258
+ if (kids.length < 2) return '';
259
+ const colW = Math.floor(9000 / cols);
260
+ let xml = `<w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/><w:tblBorders>
261
+ <w:top w:val="nil"/><w:left w:val="nil"/><w:bottom w:val="nil"/><w:right w:val="nil"/>
262
+ <w:insideH w:val="nil"/><w:insideV w:val="nil"/>
263
+ </w:tblBorders></w:tblPr><w:tblGrid>${'<w:gridCol w:w="' + colW + '"/>'.repeat(cols)}</w:tblGrid>`;
264
+ for (let i = 0; i < kids.length; i += cols) {
265
+ xml += '<w:tr>';
266
+ for (let c = 0; c < cols; c++) {
267
+ const src = kids[i + c];
268
+ const inner = src ? cellInnerXml(src, false) : `<w:p>${runsToXml([])}</w:p>`;
269
+ xml += `<w:tc><w:tcPr><w:tcW w:w="${colW}" w:type="dxa"/></w:tcPr>${inner}</w:tc>`;
206
270
  }
207
271
  xml += '</w:tr>';
208
272
  }
@@ -212,11 +276,10 @@
212
276
 
213
277
  function sheetBodyXml(sheet) {
214
278
  const blocks = [];
215
- const kids = [...sheet.children];
216
279
  const visit = (nodes) => {
217
280
  for (const el of nodes) {
218
281
  if (el.nodeType !== 1) continue;
219
- if (el.getAttribute('data-omelette-chrome') != null) continue;
282
+ if (isChrome(el)) continue;
220
283
  const tag = el.tagName.toLowerCase();
221
284
  if (tag === 'table') {
222
285
  blocks.push(tableXml(el));
@@ -226,8 +289,12 @@
226
289
  blocks.push(paraXml(el, 'Heading1'));
227
290
  continue;
228
291
  }
292
+ const gridXml = gridAsTable(el);
293
+ if (gridXml) {
294
+ blocks.push(gridXml);
295
+ continue;
296
+ }
229
297
  if (tag === 'p' || tag === 'div' || tag === 'section' || tag === 'li') {
230
- // Prefer leaf paragraphs; if a div only wraps structure, descend.
231
298
  const hasBlock = [...el.children].some((c) =>
232
299
  /^(DIV|TABLE|SECTION|UL|OL|H1|H2|H3|H4|H5|H6)$/.test(c.tagName),
233
300
  );
@@ -246,21 +313,21 @@
246
313
  visit([...el.children]);
247
314
  }
248
315
  };
249
- visit(kids);
316
+ visit([...sheet.children]);
250
317
  return blocks.filter(Boolean).join('');
251
318
  }
252
319
 
253
- function headerFooterText(sheet) {
254
- const headerEl = sheet.querySelector(':scope > div');
255
- const header = headerEl ? textRuns(headerEl).map((r) => r.t).join('').trim() : 'CyberSkill';
256
- const footer = 'CyberSkill · MST 0316475200 · Hiện Thực Hoá Ý Chí';
257
- return { header: header.slice(0, 200) || 'CyberSkill', footer };
320
+ function headerFooterText() {
321
+ return {
322
+ header: 'CyberSkill · Hiện Thực Hoá Ý Chí',
323
+ footer: 'CyberSkill · MST 0316475200 · info@cyberskill.world',
324
+ };
258
325
  }
259
326
 
260
327
  async function buildDocxBlob(sheet) {
261
328
  const page = pageSizeFromCss();
262
329
  const enc = new TextEncoder();
263
- const { header, footer } = headerFooterText(sheet);
330
+ const { header, footer } = headerFooterText();
264
331
  const body = sheetBodyXml(sheet);
265
332
 
266
333
  const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@@ -306,8 +373,7 @@
306
373
  </w:hdr>`;
307
374
 
308
375
  const footerXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
309
- <w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
310
- xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml">
376
+ <w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
311
377
  <w:p>
312
378
  <w:r><w:rPr><w:sz w:val="16"/><w:color w:val="6B5D4F"/></w:rPr>
313
379
  <w:t xml:space="preserve">${xmlEscape(footer)} · </w:t></w:r>
@@ -345,17 +411,352 @@
345
411
  });
346
412
  }
347
413
 
348
- function downloadBlob(blob, filename) {
414
+ function cssVarBlock() {
415
+ const cs = getComputedStyle(document.documentElement);
416
+ const parts = [];
417
+ for (let i = 0; i < cs.length; i++) {
418
+ const p = cs[i];
419
+ if (p.startsWith('--')) parts.push(`${p}:${cs.getPropertyValue(p)}`);
420
+ }
421
+ return parts.length ? `:root{${parts.join(';')}}` : '';
422
+ }
423
+
424
+ function dataUrlToBytes(dataUrl) {
425
+ const b64 = dataUrl.split(',')[1] || '';
426
+ const bin = atob(b64);
427
+ const out = new Uint8Array(bin.length);
428
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
429
+ return out;
430
+ }
431
+
432
+ function canvasHasInk(ctx, w, h) {
433
+ const stepX = Math.max(1, Math.floor(w / 12));
434
+ const stepY = Math.max(1, Math.floor(h / 16));
435
+ for (let y = 8; y < h; y += stepY) {
436
+ for (let x = 8; x < w; x += stepX) {
437
+ const p = ctx.getImageData(x, y, 1, 1).data;
438
+ if (p[0] < 248 || p[1] < 248 || p[2] < 248) return true;
439
+ }
440
+ }
441
+ return false;
442
+ }
443
+
444
+ function loadImage(url) {
445
+ return new Promise((resolve, reject) => {
446
+ const img = new Image();
447
+ img.onload = () => resolve(img);
448
+ img.onerror = () => reject(new Error('sheet-image-failed'));
449
+ img.src = url;
450
+ });
451
+ }
452
+
453
+ function concatBytes(parts) {
454
+ const total = parts.reduce((n, p) => n + p.length, 0);
455
+ const out = new Uint8Array(total);
456
+ let o = 0;
457
+ for (const p of parts) {
458
+ out.set(p, o);
459
+ o += p.length;
460
+ }
461
+ return out;
462
+ }
463
+
464
+ function jpegPagesToPdf(pages, mediaW, mediaH) {
465
+ const enc = new TextEncoder();
466
+ const nl = enc.encode('\n');
467
+ const chunks = [enc.encode('%PDF-1.4\n')];
468
+ const offsets = [0];
469
+ const pushObj = (id, dict, stream) => {
470
+ offsets[id] = chunks.reduce((n, c) => n + c.length, 0);
471
+ chunks.push(enc.encode(`${id} 0 obj\n`));
472
+ if (stream) {
473
+ chunks.push(enc.encode(dict));
474
+ chunks.push(nl);
475
+ chunks.push(enc.encode('stream\n'));
476
+ chunks.push(stream);
477
+ chunks.push(enc.encode('\nendstream\nendobj\n'));
478
+ } else {
479
+ chunks.push(enc.encode(dict));
480
+ chunks.push(enc.encode('\nendobj\n'));
481
+ }
482
+ };
483
+
484
+ const pageIds = [];
485
+ let nextId = 3;
486
+ for (const page of pages) {
487
+ const pageId = nextId++;
488
+ const imgId = nextId++;
489
+ const contentId = nextId++;
490
+ pageIds.push(pageId);
491
+ const content = `q ${mediaW.toFixed(2)} 0 0 ${mediaH.toFixed(2)} 0 0 cm /Im0 Do Q\n`;
492
+ pushObj(
493
+ pageId,
494
+ `<< /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 >>`,
495
+ );
496
+ pushObj(
497
+ imgId,
498
+ `<< /Type /XObject /Subtype /Image /Width ${page.w} /Height ${page.h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${page.jpeg.length} >>`,
499
+ page.jpeg,
500
+ );
501
+ const contentBytes = enc.encode(content);
502
+ pushObj(contentId, `<< /Length ${contentBytes.length} >>`, contentBytes);
503
+ }
504
+
505
+ pushObj(1, '<< /Type /Catalog /Pages 2 0 R >>');
506
+ pushObj(
507
+ 2,
508
+ `<< /Type /Pages /Kids [${pageIds.map((id) => id + ' 0 R').join(' ')}] /Count ${pageIds.length} >>`,
509
+ );
510
+
511
+ const body = concatBytes(chunks);
512
+ const xrefAt = body.length;
513
+ let xref = `xref\n0 ${nextId}\n0000000000 65535 f \n`;
514
+ for (let i = 1; i < nextId; i++) {
515
+ xref += String(offsets[i] || 0).padStart(10, '0') + ' 00000 n \n';
516
+ }
517
+ const trailer = `trailer\n<< /Size ${nextId} /Root 1 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
518
+ return concatBytes([body, enc.encode(xref), enc.encode(trailer)]);
519
+ }
520
+
521
+ function relBox(el, origin, sheet) {
522
+ const r = el.getBoundingClientRect();
523
+ return {
524
+ x: r.left - origin.left + sheet.scrollLeft,
525
+ y: r.top - origin.top + sheet.scrollTop,
526
+ w: r.width,
527
+ h: r.height,
528
+ };
529
+ }
530
+
531
+ function paintBox(ctx, el, origin, sheet) {
532
+ const cs = getComputedStyle(el);
533
+ if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return;
534
+ const b = relBox(el, origin, sheet);
535
+ const bg = cs.backgroundColor;
536
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
537
+ ctx.fillStyle = bg;
538
+ ctx.fillRect(b.x, b.y, b.w, b.h);
539
+ }
540
+ const sides = [
541
+ ['Top', b.x, b.y, b.x + b.w, b.y],
542
+ ['Right', b.x + b.w, b.y, b.x + b.w, b.y + b.h],
543
+ ['Bottom', b.x, b.y + b.h, b.x + b.w, b.y + b.h],
544
+ ['Left', b.x, b.y, b.x, b.y + b.h],
545
+ ];
546
+ for (const [side, x1, y1, x2, y2] of sides) {
547
+ const w = parseFloat(cs[`border${side}Width`]) || 0;
548
+ if (w <= 0 || cs[`border${side}Style`] === 'none') continue;
549
+ ctx.strokeStyle = cs[`border${side}Color`];
550
+ ctx.lineWidth = w;
551
+ ctx.beginPath();
552
+ ctx.moveTo(x1, y1);
553
+ ctx.lineTo(x2, y2);
554
+ ctx.stroke();
555
+ }
556
+ }
557
+
558
+ function paintTextNode(ctx, node, origin, sheet) {
559
+ const raw = node.textContent || '';
560
+ if (!raw.replace(/\s+/g, '').length) return;
561
+ const parent = node.parentElement;
562
+ if (!parent) return;
563
+ const cs = getComputedStyle(parent);
564
+ if (cs.display === 'none' || cs.visibility === 'hidden') return;
565
+ ctx.save();
566
+ ctx.fillStyle = cs.color || '#000';
567
+ ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
568
+ ctx.textBaseline = 'top';
569
+ const range = document.createRange();
570
+ const parts = raw.split(/(\s+)/);
571
+ let offset = 0;
572
+ for (const part of parts) {
573
+ const next = offset + part.length;
574
+ if (part.replace(/\s+/g, '').length) {
575
+ try {
576
+ range.setStart(node, offset);
577
+ range.setEnd(node, Math.min(next, raw.length));
578
+ const r = range.getBoundingClientRect();
579
+ if (r.width || r.height) {
580
+ ctx.fillText(
581
+ part,
582
+ r.left - origin.left + sheet.scrollLeft,
583
+ r.top - origin.top + sheet.scrollTop,
584
+ );
585
+ }
586
+ } catch (_) {
587
+ /* invalid offsets on some hosts */
588
+ }
589
+ }
590
+ offset = next;
591
+ }
592
+ ctx.restore();
593
+ }
594
+
595
+ async function paintSvg(ctx, svg, origin, sheet) {
596
+ const b = relBox(svg, origin, sheet);
597
+ if (b.w < 1 || b.h < 1) return;
598
+ try {
599
+ const xml = new XMLSerializer().serializeToString(svg);
600
+ if (/foreignObject/i.test(xml)) return;
601
+ const img = await loadImage('data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml));
602
+ ctx.drawImage(img, b.x, b.y, b.w, b.h);
603
+ } catch (_) {
604
+ /* skip logo if SVG paint fails */
605
+ }
606
+ }
607
+
608
+ async function paintSheet(sheet, scale) {
609
+ const origin = sheet.getBoundingClientRect();
610
+ const width = Math.max(sheet.scrollWidth, sheet.clientWidth, 1);
611
+ const height = Math.max(sheet.scrollHeight, sheet.clientHeight, 1);
612
+ const canvas = document.createElement('canvas');
613
+ canvas.width = Math.max(1, Math.round(width * scale));
614
+ canvas.height = Math.max(1, Math.round(height * scale));
615
+ const ctx = canvas.getContext('2d');
616
+ ctx.scale(scale, scale);
617
+ ctx.fillStyle = '#ffffff';
618
+ ctx.fillRect(0, 0, width, height);
619
+ const svgs = [];
620
+ const walk = (node) => {
621
+ if (node.nodeType === 1) {
622
+ if (isChrome(node)) return;
623
+ const tag = node.tagName.toLowerCase();
624
+ if (tag === 'script' || tag === 'style' || tag === 'noscript') return;
625
+ if (tag === 'svg') {
626
+ paintBox(ctx, node, origin, sheet);
627
+ svgs.push(node);
628
+ return;
629
+ }
630
+ paintBox(ctx, node, origin, sheet);
631
+ for (const c of node.childNodes) walk(c);
632
+ return;
633
+ }
634
+ if (node.nodeType === 3) paintTextNode(ctx, node, origin, sheet);
635
+ };
636
+ walk(sheet);
637
+ for (const svg of svgs) await paintSvg(ctx, svg, origin, sheet);
638
+ return { canvas, width, height, ctx };
639
+ }
640
+
641
+ async function rasterizeSheet(sheet) {
642
+ const page = pageSizeFromCss();
643
+ const scale = 2;
644
+ const painted = await paintSheet(sheet, scale);
645
+ const { canvas, width, height, ctx } = painted;
646
+ let ink = true;
647
+ try {
648
+ ink = canvasHasInk(ctx, canvas.width, Math.min(canvas.height, 400));
649
+ } catch (_) {
650
+ ink = true;
651
+ }
652
+ if (!ink) throw new Error('blank-raster');
653
+ const pageH = Math.round(width * (page.h / page.w) * scale);
654
+ const pages = [];
655
+ let y = 0;
656
+ while (y < canvas.height - 1) {
657
+ const ch = Math.min(pageH, canvas.height - y);
658
+ const slice = document.createElement('canvas');
659
+ slice.width = canvas.width;
660
+ slice.height = Math.max(1, ch);
661
+ const sctx = slice.getContext('2d');
662
+ sctx.fillStyle = '#ffffff';
663
+ sctx.fillRect(0, 0, slice.width, slice.height);
664
+ sctx.drawImage(canvas, 0, y, canvas.width, ch, 0, 0, canvas.width, ch);
665
+ pages.push({
666
+ jpeg: dataUrlToBytes(slice.toDataURL('image/jpeg', 0.92)),
667
+ w: slice.width,
668
+ h: slice.height,
669
+ });
670
+ y += pageH;
671
+ if (pages.length > 40) break;
672
+ }
673
+ if (!pages.length) throw new Error('no-pages');
674
+ return jpegPagesToPdf(pages, page.ptW, page.ptH);
675
+ }
676
+
677
+ async function buildPdfBlob(sheet) {
678
+ const bytes = await rasterizeSheet(sheet);
679
+ return new Blob([bytes], { type: 'application/pdf' });
680
+ }
681
+
682
+ function printPdf() {
683
+ const sheet = document.querySelector('.cs-sheet') || document.body;
684
+ const page = pageSizeFromCss();
685
+ const size = page.name === 'Letter' ? 'letter' : 'A4';
686
+ const clone = sheet.cloneNode(true);
687
+ clone.querySelectorAll('[data-omelette-chrome]').forEach((n) => n.remove());
688
+ const links = [...document.querySelectorAll('link[rel="stylesheet"]')]
689
+ .map((l) => `<link rel="stylesheet" href="${xmlEscape(l.href)}">`)
690
+ .join('');
691
+ const styles = [...document.querySelectorAll('style')]
692
+ .filter((s) => s.getAttribute('data-omelette-chrome') == null)
693
+ .map((s) => `<style>${s.textContent || ''}</style>`)
694
+ .join('');
695
+ const title = xmlEscape(document.title || 'document');
696
+ const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${title}</title>
697
+ ${links}${styles}
698
+ <style>${cssVarBlock()}
699
+ @page{size:${size};margin:16mm 14mm}
700
+ html,body{margin:0;padding:0;background:#fff!important;height:auto!important;max-height:none!important;overflow:visible!important}
701
+ .cs-desk{background:#fff!important;padding:0!important;min-height:0!important}
702
+ .cs-sheet{box-shadow:none!important;margin:0 auto!important}
703
+ [data-omelette-chrome]{display:none!important}
704
+ </style></head><body>${clone.outerHTML}</body></html>`;
705
+ const url = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=utf-8' }));
706
+ let w = null;
707
+ try {
708
+ w = window.open(url, '_blank');
709
+ } catch (_) {
710
+ w = null;
711
+ }
712
+ if (!w) {
713
+ URL.revokeObjectURL(url);
714
+ window.print();
715
+ return;
716
+ }
717
+ let printed = false;
718
+ const go = () => {
719
+ if (printed) return;
720
+ printed = true;
721
+ try {
722
+ w.focus();
723
+ w.print();
724
+ } catch (_) {
725
+ /* ignore */
726
+ }
727
+ setTimeout(() => URL.revokeObjectURL(url), 4000);
728
+ };
729
+ w.addEventListener('load', () => setTimeout(go, 400));
730
+ setTimeout(go, 1600);
731
+ }
732
+
733
+ async function downloadBlob(blob, filename) {
734
+ const type = blob.type || 'application/octet-stream';
735
+ try {
736
+ const file = new File([blob], filename, { type });
737
+ if (navigator.canShare && navigator.canShare({ files: [file] })) {
738
+ try {
739
+ await navigator.share({ files: [file], title: filename });
740
+ return;
741
+ } catch (e) {
742
+ if (e && e.name === 'AbortError') return;
743
+ }
744
+ }
745
+ } catch (_) {
746
+ /* File/share unsupported */
747
+ }
748
+ const url = URL.createObjectURL(blob);
349
749
  const a = document.createElement('a');
350
- a.href = URL.createObjectURL(blob);
750
+ a.href = url;
351
751
  a.download = filename;
352
752
  a.rel = 'noopener';
753
+ a.target = '_blank';
353
754
  document.body.appendChild(a);
354
755
  a.click();
355
756
  setTimeout(() => {
356
- URL.revokeObjectURL(a.href);
757
+ URL.revokeObjectURL(url);
357
758
  a.remove();
358
- }, 1500);
759
+ }, 4000);
359
760
  }
360
761
 
361
762
  function suggestName(ext) {
@@ -363,38 +764,22 @@
363
764
  return t.replace(/[^\w\u00C0-\u024F\- ]+/g, '').replace(/\s+/g, '-').slice(0, 80) + ext;
364
765
  }
365
766
 
366
- /**
367
- * Safari/iOS often prints a blank page from a Storybook iframe (clips to the
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() {
767
+ async function downloadPdf() {
768
+ const sheet = document.querySelector('.cs-sheet') || document.body;
372
769
  try {
373
- const framed = window.top != null && window.top !== window;
374
- if (framed) {
375
- const w = window.open(location.href, '_blank');
376
- if (w) {
377
- let printed = false;
378
- const go = () => {
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 */
770
+ const blob = await buildPdfBlob(sheet);
771
+ if (!blob || blob.size < 200) throw new Error('empty-pdf');
772
+ await downloadBlob(blob, suggestName('.pdf'));
773
+ } catch (e) {
774
+ console.warn('[doc-export] raster PDF failed, printing sheet clone', e);
775
+ printPdf();
396
776
  }
397
- window.print();
777
+ }
778
+
779
+ async function downloadDocx() {
780
+ const sheet = document.querySelector('.cs-sheet') || document.body;
781
+ const blob = await buildDocxBlob(sheet);
782
+ await downloadBlob(blob, suggestName('.docx'));
398
783
  }
399
784
 
400
785
  function injectToolbar() {
@@ -406,7 +791,7 @@
406
791
  style.setAttribute('data-omelette-chrome', 'export-style');
407
792
  style.textContent = `
408
793
  [data-omelette-chrome="export"]{
409
- position:fixed;z-index:2147483000;top:12px;right:12px;display:flex;gap:8px;
794
+ position:fixed;z-index:2147483000;top:12px;right:12px;display:flex;flex-direction:column;align-items:flex-end;gap:4px;
410
795
  font-family:var(--cs-font-family-ui,system-ui,sans-serif);pointer-events:auto;
411
796
  }
412
797
  [data-omelette-chrome="export"] button{
@@ -416,10 +801,16 @@
416
801
  }
417
802
  [data-omelette-chrome="export"] button:hover{background:#f7f0e6}
418
803
  [data-omelette-chrome="export"] button:focus-visible{outline:2px solid #F4BA17;outline-offset:2px}
804
+ [data-cs-export="menu"]{
805
+ min-width:148px;background:#fff;border:1px solid #c9b8a8;border-radius:8px;
806
+ box-shadow:0 8px 24px rgba(35,26,18,.12);overflow:hidden;padding:4px;
807
+ }
808
+ [data-cs-export="menu"][hidden]{display:none!important}
809
+ [data-cs-export="menu"] button{
810
+ display:block;width:100%;border:0;border-radius:6px;box-shadow:none;text-align:left;font-weight:650;
811
+ }
419
812
  @media print{
420
813
  [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
814
  html,body,#dc-root,#dc-root>.sc-host{
424
815
  height:auto!important;min-height:0!important;max-height:none!important;overflow:visible!important
425
816
  }
@@ -429,39 +820,86 @@
429
820
 
430
821
  const bar = document.createElement('div');
431
822
  bar.setAttribute('data-omelette-chrome', 'export');
432
- bar.setAttribute('role', 'toolbar');
433
- bar.setAttribute('aria-label', 'Document export · Xuất tài liệu');
434
-
435
- const pdfBtn = document.createElement('button');
436
- pdfBtn.type = 'button';
437
- pdfBtn.textContent = 'PDF · In / Print';
438
- pdfBtn.addEventListener('click', () => printPdf());
439
-
440
- const docxBtn = document.createElement('button');
441
- docxBtn.type = 'button';
442
- docxBtn.textContent = 'DOCX · Tải / Download';
443
- docxBtn.addEventListener('click', async () => {
823
+ bar.setAttribute('role', 'group');
824
+ bar.setAttribute('aria-label', 'Tải tài liệu · Download document');
825
+
826
+ const trigger = document.createElement('button');
827
+ trigger.type = 'button';
828
+ trigger.setAttribute('data-cs-export', 'trigger');
829
+ trigger.setAttribute('aria-haspopup', 'menu');
830
+ trigger.setAttribute('aria-expanded', 'false');
831
+ trigger.textContent = 'Tải / Download ▾';
832
+
833
+ const menu = document.createElement('div');
834
+ menu.setAttribute('data-cs-export', 'menu');
835
+ menu.setAttribute('role', 'menu');
836
+ menu.hidden = true;
837
+
838
+ const pdfItem = document.createElement('button');
839
+ pdfItem.type = 'button';
840
+ pdfItem.setAttribute('role', 'menuitem');
841
+ pdfItem.setAttribute('data-cs-export', 'pdf');
842
+ pdfItem.textContent = 'PDF';
843
+
844
+ const docxItem = document.createElement('button');
845
+ docxItem.type = 'button';
846
+ docxItem.setAttribute('role', 'menuitem');
847
+ docxItem.setAttribute('data-cs-export', 'docx');
848
+ docxItem.textContent = 'DOCX';
849
+
850
+ const setOpen = (open) => {
851
+ menu.hidden = !open;
852
+ trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
853
+ };
854
+
855
+ trigger.addEventListener('click', () => setOpen(menu.hidden));
856
+ trigger.addEventListener('keydown', (e) => {
857
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
858
+ e.preventDefault();
859
+ setOpen(true);
860
+ pdfItem.focus();
861
+ }
862
+ });
863
+ menu.addEventListener('keydown', (e) => {
864
+ if (e.key === 'Escape') {
865
+ e.preventDefault();
866
+ setOpen(false);
867
+ trigger.focus();
868
+ }
869
+ });
870
+ document.addEventListener('click', (e) => {
871
+ if (!bar.contains(e.target)) setOpen(false);
872
+ });
873
+
874
+ pdfItem.addEventListener('click', async () => {
875
+ setOpen(false);
876
+ await downloadPdf();
877
+ });
878
+ docxItem.addEventListener('click', async () => {
879
+ setOpen(false);
444
880
  try {
445
- const sheet = document.querySelector('.cs-sheet') || document.body;
446
- const blob = await buildDocxBlob(sheet);
447
- downloadBlob(blob, suggestName('.docx'));
881
+ await downloadDocx();
448
882
  } catch (e) {
449
883
  console.error('[doc-export] DOCX failed', e);
450
884
  alert('DOCX export failed: ' + (e && e.message ? e.message : e));
451
885
  }
452
886
  });
453
887
 
454
- bar.appendChild(pdfBtn);
455
- bar.appendChild(docxBtn);
888
+ menu.appendChild(pdfItem);
889
+ menu.appendChild(docxItem);
890
+ bar.appendChild(trigger);
891
+ bar.appendChild(menu);
456
892
  document.body.appendChild(bar);
457
893
  }
458
894
 
459
- // Test / automation hooks
460
895
  window.__csDocExport = {
461
896
  buildDocxBlob,
897
+ buildPdfBlob,
462
898
  pageSizeFromCss,
463
899
  injectToolbar,
464
900
  printPdf,
901
+ downloadPdf,
902
+ downloadDocx,
465
903
  };
466
904
 
467
905
  const boot = () => injectToolbar();
@@ -470,6 +908,5 @@
470
908
  } else {
471
909
  boot();
472
910
  }
473
- // DC boots async — re-try after paint so .cs-sheet exists for future exports.
474
911
  setTimeout(boot, 800);
475
912
  })();