@erclx/canon 5.20.0 → 5.22.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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/auto-ship/references/draft-mark.md +8 -6
- package/claude/skills/auto-ship/references/verb-absent.md +1 -1
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
- package/claude/skills/draft-doc/SKILL.md +77 -0
- package/claude/skills/draft-doc/references/context.md +31 -0
- package/claude/skills/draft-doc/references/docs.md +33 -0
- package/claude/skills/draft-doc/references/readme.md +58 -0
- package/claude/skills/draft-doc/references/wiki.md +53 -0
- package/claude/skills/draft-doc/references/wireframes.md +48 -0
- package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +1 -1
- package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
- package/claude/skills/draft-ready/SKILL.md +6 -4
- package/claude/skills/git-pr/SKILL.md +5 -46
- package/claude/skills/git-pr/references/final-command.md +75 -0
- package/claude/skills/git-pr/references/labels.md +2 -2
- package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
- package/claude/skills/markdown-propose/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
- package/claude/skills/search-craft/REQUIREMENT.md +4 -4
- package/claude/skills/search-craft/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -0
- package/docs/agents/drift-surfaces.md +20 -9
- package/docs/agents/index.md +2 -1
- package/docs/agents/install-and-sync.md +3 -3
- package/docs/agents/markdown-audit.md +1 -3
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/slides.md +164 -0
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/tasks.md +2 -2
- package/docs/target/migrations.md +4 -2
- package/docs/target/sync.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +25 -31
- package/governance/rules/canon/604-scratch.md +0 -1
- package/governance/rules/claude/575-hooks.md +1 -2
- package/governance/rules/claude/576-settings.md +1 -2
- package/governance/rules/lib/330-testing-py.md +1 -1
- package/governance/rules/lib/336-testing-php.md +1 -1
- package/governance/rules/standards/architecture.md +0 -1
- package/governance/rules/standards/context.md +0 -1
- package/governance/rules/standards/design.md +0 -1
- package/governance/rules/standards/diagrams.md +0 -3
- package/governance/rules/standards/requirements.md +0 -1
- package/governance/rules/standards/wireframes.md +0 -1
- package/package.json +1 -1
- package/scripts/manage-sandbox.sh +7 -8
- package/src/claude/cases/authoring.ts +5 -5
- package/src/claude/cases/workflow.ts +4 -20
- package/src/commands/slides.ts +23 -3
- package/src/commands/sync.ts +10 -0
- package/src/commands/tasks.ts +46 -2
- package/src/gov/citations.ts +1 -2
- package/src/migrate/skill-names.ts +12 -7
- package/src/slides/convert/chart.ts +165 -0
- package/src/slides/convert/deck.ts +244 -0
- package/src/slides/convert/export.ts +281 -23
- package/src/slides/convert/master.ts +195 -0
- package/src/slides/convert/shapes.ts +44 -4
- package/src/slides/convert/walk.ts +229 -2
- package/src/slides/package/fonts.ts +297 -0
- package/src/slides/package/motion.ts +316 -0
- package/src/slides/package/test-face.ts +113 -0
- package/src/sync/check.ts +9 -0
- package/src/sync/layout.ts +27 -1
- package/src/tasks/record.ts +11 -1
- package/src/tasks/validate.ts +14 -0
- package/standards/architecture.md +0 -1
- package/standards/context.md +0 -1
- package/standards/design.md +0 -1
- package/standards/diagrams.md +0 -3
- package/standards/requirements.md +0 -1
- package/standards/rule.md +1 -0
- package/standards/tasks.md +1 -1
- package/standards/wireframes.md +0 -1
- package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +8 -3
- package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
- package/claude/skills/docs-fold/SKILL.md +0 -9
- package/claude/skills/draft-context/REQUIREMENT.md +0 -37
- package/claude/skills/draft-context/SKILL.md +0 -61
- package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
- package/claude/skills/draft-docs/SKILL.md +0 -59
- package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
- package/claude/skills/draft-readme/SKILL.md +0 -73
- package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
- package/claude/skills/draft-wiki/SKILL.md +0 -83
- package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
- package/claude/skills/draft-wireframes/SKILL.md +0 -69
- package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
- package/claude/skills/standards-audit/SKILL.md +0 -99
|
@@ -150,7 +150,7 @@ const PT_PER_PX = 0.75
|
|
|
150
150
|
const inches = (px: number): number => px / PX_PER_INCH
|
|
151
151
|
const points = (px: number): number => px * PT_PER_PX
|
|
152
152
|
|
|
153
|
-
function position(box: Rect): PptxGenJS.PositionProps {
|
|
153
|
+
export function position(box: Rect): PptxGenJS.PositionProps {
|
|
154
154
|
return {
|
|
155
155
|
x: inches(box.x),
|
|
156
156
|
y: inches(box.y),
|
|
@@ -521,10 +521,44 @@ export interface Fallback {
|
|
|
521
521
|
readonly properties: readonly UnmappedProperty[]
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
+
/** A shape the slide will hold, by the record that produced it. */
|
|
525
|
+
export interface NamedShape {
|
|
526
|
+
readonly record: number
|
|
527
|
+
readonly name: string
|
|
528
|
+
}
|
|
529
|
+
|
|
524
530
|
export interface SlidePlan {
|
|
525
531
|
readonly ops: readonly DrawOp[]
|
|
526
532
|
readonly fallbacks: readonly Fallback[]
|
|
527
533
|
readonly refusedLinks: readonly RefusedLink[]
|
|
534
|
+
/** Every object name the ops carry, in the order they are drawn. */
|
|
535
|
+
readonly names: readonly NamedShape[]
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* pptxgenjs numbers shape ids itself, so the object name is the only handle a
|
|
540
|
+
* later pass over the written XML can find a shape by.
|
|
541
|
+
*/
|
|
542
|
+
export const objectNameOf = (record: number): string => `canon-${record}`
|
|
543
|
+
|
|
544
|
+
/** An image's border is a second shape over it, named after the picture. */
|
|
545
|
+
function named<T extends DrawOp>(op: T, record: number): T {
|
|
546
|
+
const name = objectNameOf(record)
|
|
547
|
+
const options = { ...op.options, objectName: name }
|
|
548
|
+
if (op.kind === 'image' && op.frame) {
|
|
549
|
+
return {
|
|
550
|
+
...op,
|
|
551
|
+
options,
|
|
552
|
+
frame: { ...op.frame, objectName: `${name}-frame` },
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return { ...op, options }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function namesOf(record: number, op: DrawOp): NamedShape[] {
|
|
559
|
+
const names = [op.options.objectName]
|
|
560
|
+
if (op.kind === 'image') names.push(op.frame?.objectName)
|
|
561
|
+
return names.flatMap((name) => (name ? [{ record, name }] : []))
|
|
528
562
|
}
|
|
529
563
|
|
|
530
564
|
function boxOp(record: ElementRecord): DrawOp | undefined {
|
|
@@ -729,7 +763,13 @@ export function planSlide(
|
|
|
729
763
|
const ops: DrawOp[] = []
|
|
730
764
|
const fallbacks: Fallback[] = []
|
|
731
765
|
const refusedLinks: RefusedLink[] = []
|
|
766
|
+
const names: NamedShape[] = []
|
|
732
767
|
const dropped = new Set<number>()
|
|
768
|
+
const add = (record: number, op: DrawOp): void => {
|
|
769
|
+
const withName = named(op, record)
|
|
770
|
+
ops.push(withName)
|
|
771
|
+
names.push(...namesOf(record, withName))
|
|
772
|
+
}
|
|
733
773
|
|
|
734
774
|
for (const record of records) {
|
|
735
775
|
if (record.parent !== null && dropped.has(record.parent)) {
|
|
@@ -740,7 +780,7 @@ export function planSlide(
|
|
|
740
780
|
if (properties.length > 0) {
|
|
741
781
|
dropped.add(record.id)
|
|
742
782
|
fallbacks.push({ id: record.id, selector: record.selector, properties })
|
|
743
|
-
|
|
783
|
+
add(record.id, {
|
|
744
784
|
kind: 'fallback',
|
|
745
785
|
id: record.id,
|
|
746
786
|
options: { ...position(record.bounds), altText: record.text },
|
|
@@ -757,8 +797,8 @@ export function planSlide(
|
|
|
757
797
|
: record.kind === 'svg'
|
|
758
798
|
? svgOp(record)
|
|
759
799
|
: tableOp(record, context, refusedLinks)
|
|
760
|
-
if (op)
|
|
800
|
+
if (op) add(record.id, op)
|
|
761
801
|
}
|
|
762
802
|
|
|
763
|
-
return { ops, fallbacks, refusedLinks }
|
|
803
|
+
return { ops, fallbacks, refusedLinks, names }
|
|
764
804
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { ChartRecord } from '@/slides/convert/chart'
|
|
2
|
+
import type { ThemeReading } from '@/slides/convert/master'
|
|
3
|
+
import type { TransitionSpec } from '@/slides/package/motion'
|
|
1
4
|
import type {
|
|
2
5
|
BoxStyle,
|
|
3
6
|
ElementRecord,
|
|
@@ -9,10 +12,49 @@ import type {
|
|
|
9
12
|
TextRun,
|
|
10
13
|
} from '@/slides/convert/shapes'
|
|
11
14
|
|
|
15
|
+
/** How one slide treats a band the deck master draws. */
|
|
16
|
+
export type BandOverride =
|
|
17
|
+
| { readonly kind: 'master' }
|
|
18
|
+
| { readonly kind: 'off' }
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: 'override'
|
|
21
|
+
readonly left?: string
|
|
22
|
+
readonly center?: string
|
|
23
|
+
readonly right?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What a slide declares about itself on `<body>` and in its notes. */
|
|
27
|
+
export interface SlideMeta {
|
|
28
|
+
readonly notes?: string
|
|
29
|
+
readonly section?: string
|
|
30
|
+
readonly isHidden: boolean
|
|
31
|
+
readonly header: BandOverride
|
|
32
|
+
readonly footer: BandOverride
|
|
33
|
+
/** The `--color-text` the slide's `<body>` computes, as hex. */
|
|
34
|
+
readonly textToken?: string
|
|
35
|
+
/** `data-transition` and `data-transition-duration` on `<body>`. */
|
|
36
|
+
readonly transition?: TransitionSpec
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An element's `data-enter`, with the record it was walked as, or null when it
|
|
41
|
+
* was folded into another record's text or never drawn.
|
|
42
|
+
*/
|
|
43
|
+
export interface EntranceRecord {
|
|
44
|
+
readonly record: number | null
|
|
45
|
+
readonly selector: string
|
|
46
|
+
readonly effect: string
|
|
47
|
+
readonly order?: string
|
|
48
|
+
readonly duration?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
12
51
|
/** What one laid-out slide hands back across `page.evaluate`. */
|
|
13
52
|
export interface WalkedSlide {
|
|
14
53
|
readonly background: Rgba
|
|
15
54
|
readonly records: ElementRecord[]
|
|
55
|
+
readonly charts: ChartRecord[]
|
|
56
|
+
readonly entrances: EntranceRecord[]
|
|
57
|
+
readonly meta: SlideMeta
|
|
16
58
|
}
|
|
17
59
|
|
|
18
60
|
/** Set on every walked element, so a screenshot finds the one a record names. */
|
|
@@ -27,7 +69,8 @@ export const ID_ATTRIBUTE = 'data-canon-id'
|
|
|
27
69
|
* Text gathers along the inline flow. A block holding text takes every run its
|
|
28
70
|
* inline descendants carry, so `<strong>` inside `<p>` is one shape with two
|
|
29
71
|
* runs, while a block-level child is walked on its own. Images, SVGs, and
|
|
30
|
-
* tables consume their whole subtree.
|
|
72
|
+
* tables consume their whole subtree. Speaker notes in `<aside class="notes">`
|
|
73
|
+
* and a chart's `<figure data-chart>` are read whole and never drawn as shapes.
|
|
31
74
|
*
|
|
32
75
|
* Each walked element is tagged with `idAttribute` set to its record id.
|
|
33
76
|
*/
|
|
@@ -293,10 +336,41 @@ export function walkSlide(idAttribute: string): WalkedSlide {
|
|
|
293
336
|
|
|
294
337
|
const SKIPPED = new Set(['script', 'style', 'template', 'noscript', 'link'])
|
|
295
338
|
|
|
339
|
+
const cellText = (element: Element): string =>
|
|
340
|
+
(element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
341
|
+
|
|
342
|
+
/** One line per block, so a paragraph break survives and no tag does. */
|
|
343
|
+
const notesText = (element: Element): string => {
|
|
344
|
+
const clone = element.cloneNode(true)
|
|
345
|
+
if (!(clone instanceof Element)) return ''
|
|
346
|
+
for (const br of Array.from(clone.querySelectorAll('br'))) {
|
|
347
|
+
br.replaceWith('\n')
|
|
348
|
+
}
|
|
349
|
+
for (const block of Array.from(
|
|
350
|
+
clone.querySelectorAll('p, li, div, blockquote, h1, h2, h3, h4, h5, h6'),
|
|
351
|
+
)) {
|
|
352
|
+
block.append('\n')
|
|
353
|
+
}
|
|
354
|
+
return (clone.textContent ?? '')
|
|
355
|
+
.split('\n')
|
|
356
|
+
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
357
|
+
.filter(Boolean)
|
|
358
|
+
.join('\n')
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const notes: string[] = []
|
|
362
|
+
const charts: ChartRecord[] = []
|
|
363
|
+
|
|
296
364
|
for (const element of Array.from(document.body.querySelectorAll('*'))) {
|
|
297
365
|
if (consumed.has(element)) continue
|
|
298
366
|
const tag = element.tagName.toLowerCase()
|
|
299
367
|
if (SKIPPED.has(tag)) continue
|
|
368
|
+
if (tag === 'aside' && element.classList.contains('notes')) {
|
|
369
|
+
consumeSubtree(element)
|
|
370
|
+
const text = notesText(element)
|
|
371
|
+
if (text) notes.push(text)
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
300
374
|
if (!element.checkVisibility({ visibilityProperty: true })) continue
|
|
301
375
|
|
|
302
376
|
const id = records.length + 1
|
|
@@ -313,6 +387,27 @@ export function walkSlide(idAttribute: string): WalkedSlide {
|
|
|
313
387
|
ids.set(element, id)
|
|
314
388
|
element.setAttribute(idAttribute, String(id))
|
|
315
389
|
|
|
390
|
+
if (tag === 'figure' && element.hasAttribute('data-chart')) {
|
|
391
|
+
consumeSubtree(element)
|
|
392
|
+
records.push({ ...base, kind: 'box' })
|
|
393
|
+
const table = element.querySelector('table')
|
|
394
|
+
const caption = element.querySelector('figcaption')
|
|
395
|
+
const title = caption ? cellText(caption) : ''
|
|
396
|
+
charts.push({
|
|
397
|
+
selector: base.selector,
|
|
398
|
+
box: bounds,
|
|
399
|
+
type: element.getAttribute('data-chart') ?? '',
|
|
400
|
+
hasLabels: element.hasAttribute('data-labels'),
|
|
401
|
+
...(title ? { title } : {}),
|
|
402
|
+
rows: table
|
|
403
|
+
? Array.from(table.rows).map((row) =>
|
|
404
|
+
Array.from(row.cells).map(cellText),
|
|
405
|
+
)
|
|
406
|
+
: [],
|
|
407
|
+
})
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
|
|
316
411
|
if (element instanceof HTMLImageElement) {
|
|
317
412
|
records.push({
|
|
318
413
|
...base,
|
|
@@ -440,5 +535,137 @@ export function walkSlide(idAttribute: string): WalkedSlide {
|
|
|
440
535
|
? htmlGround
|
|
441
536
|
: { hex: 'FFFFFF', alpha: 1 }
|
|
442
537
|
|
|
443
|
-
|
|
538
|
+
const body = document.body
|
|
539
|
+
const band = (kind: 'header' | 'footer'): BandOverride => {
|
|
540
|
+
if (body.getAttribute(`data-${kind}`) === 'off') return { kind: 'off' }
|
|
541
|
+
const slots: { left?: string; center?: string; right?: string } = {}
|
|
542
|
+
for (const slot of ['left', 'center', 'right'] as const) {
|
|
543
|
+
const value = body.getAttribute(`data-${kind}-${slot}`)
|
|
544
|
+
if (value !== null) slots[slot] = value
|
|
545
|
+
}
|
|
546
|
+
return Object.keys(slots).length > 0
|
|
547
|
+
? { kind: 'override', ...slots }
|
|
548
|
+
: { kind: 'master' }
|
|
549
|
+
}
|
|
550
|
+
const section = body.getAttribute('data-section')?.trim()
|
|
551
|
+
const attribute = (element: Element, name: string): string | undefined =>
|
|
552
|
+
element.getAttribute(name)?.trim() || undefined
|
|
553
|
+
const transitionEffect = attribute(body, 'data-transition')
|
|
554
|
+
const transitionDuration = attribute(body, 'data-transition-duration')
|
|
555
|
+
const textValue = getComputedStyle(body)
|
|
556
|
+
.getPropertyValue('--color-text')
|
|
557
|
+
.trim()
|
|
558
|
+
const meta: SlideMeta = {
|
|
559
|
+
...(textValue ? { textToken: rgba(textValue).hex } : {}),
|
|
560
|
+
...(notes.length > 0 ? { notes: notes.join('\n\n') } : {}),
|
|
561
|
+
...(section ? { section } : {}),
|
|
562
|
+
isHidden:
|
|
563
|
+
body.hasAttribute('data-hidden') &&
|
|
564
|
+
body.getAttribute('data-hidden') !== 'false',
|
|
565
|
+
header: band('header'),
|
|
566
|
+
footer: band('footer'),
|
|
567
|
+
...(transitionEffect
|
|
568
|
+
? {
|
|
569
|
+
transition: {
|
|
570
|
+
effect: transitionEffect,
|
|
571
|
+
...(transitionDuration ? { duration: transitionDuration } : {}),
|
|
572
|
+
},
|
|
573
|
+
}
|
|
574
|
+
: {}),
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const entrances: EntranceRecord[] = Array.from(
|
|
578
|
+
body.querySelectorAll('[data-enter]'),
|
|
579
|
+
).map((element) => {
|
|
580
|
+
const order = attribute(element, 'data-enter-order')
|
|
581
|
+
const duration = attribute(element, 'data-enter-duration')
|
|
582
|
+
return {
|
|
583
|
+
record: ids.get(element) ?? null,
|
|
584
|
+
selector: describe(element),
|
|
585
|
+
effect: attribute(element, 'data-enter') ?? '',
|
|
586
|
+
...(order ? { order } : {}),
|
|
587
|
+
...(duration ? { duration } : {}),
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
return { background, records, charts, entrances, meta }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Runs inside the laid-out page and reads the colors and face the deck master
|
|
596
|
+
* takes. Tokens come from `<html>`'s computed style, so a slide that switches
|
|
597
|
+
* theme on its own `<body>`, such as a dark cover, leaves the master on the
|
|
598
|
+
* project's root theme. The body supplies the fallback colors and the face.
|
|
599
|
+
* Serialized to source like `walkSlide`, so its helpers sit in its own body.
|
|
600
|
+
*/
|
|
601
|
+
export function readTheme(): ThemeReading {
|
|
602
|
+
const canvas = document.createElement('canvas')
|
|
603
|
+
canvas.width = 1
|
|
604
|
+
canvas.height = 1
|
|
605
|
+
const context = canvas.getContext('2d', { willReadFrequently: true })
|
|
606
|
+
const hex = (value: string): string | undefined => {
|
|
607
|
+
if (!context || !value || value === 'transparent') return undefined
|
|
608
|
+
context.clearRect(0, 0, 1, 1)
|
|
609
|
+
context.fillStyle = '#000000'
|
|
610
|
+
context.fillStyle = value
|
|
611
|
+
context.fillRect(0, 0, 1, 1)
|
|
612
|
+
const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data
|
|
613
|
+
if (a === 0) return undefined
|
|
614
|
+
return [r, g, b]
|
|
615
|
+
.map((channel) => channel.toString(16).padStart(2, '0'))
|
|
616
|
+
.join('')
|
|
617
|
+
.toUpperCase()
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const style = getComputedStyle(document.body)
|
|
621
|
+
const rootStyle = getComputedStyle(document.documentElement)
|
|
622
|
+
const token = (name: string): string | undefined => {
|
|
623
|
+
const value = rootStyle.getPropertyValue(name).trim()
|
|
624
|
+
return value ? hex(value) : undefined
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const names: string[] = []
|
|
628
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
629
|
+
let rules: CSSRuleList
|
|
630
|
+
try {
|
|
631
|
+
rules = sheet.cssRules
|
|
632
|
+
} catch {
|
|
633
|
+
continue
|
|
634
|
+
}
|
|
635
|
+
for (const rule of Array.from(rules)) {
|
|
636
|
+
if (!(rule instanceof CSSStyleRule)) continue
|
|
637
|
+
for (const name of Array.from(rule.style)) {
|
|
638
|
+
if (name.startsWith('--color-') && !names.includes(name)) {
|
|
639
|
+
names.push(name)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const tokens: {
|
|
646
|
+
background?: string
|
|
647
|
+
text?: string
|
|
648
|
+
muted?: string
|
|
649
|
+
accent?: string
|
|
650
|
+
} = {}
|
|
651
|
+
for (const role of ['background', 'text', 'muted', 'accent'] as const) {
|
|
652
|
+
const value = token(`--color-${role}`)
|
|
653
|
+
if (value) tokens[role] = value
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
tokens,
|
|
658
|
+
body: {
|
|
659
|
+
background:
|
|
660
|
+
hex(style.backgroundColor) ??
|
|
661
|
+
hex(getComputedStyle(document.documentElement).backgroundColor) ??
|
|
662
|
+
'FFFFFF',
|
|
663
|
+
color: hex(style.color) ?? '000000',
|
|
664
|
+
},
|
|
665
|
+
fontFamily: style.fontFamily,
|
|
666
|
+
roles: names.flatMap((name) => {
|
|
667
|
+
const value = token(name)
|
|
668
|
+
return value ? [{ name, hex: value }] : []
|
|
669
|
+
}),
|
|
670
|
+
}
|
|
444
671
|
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type JSZip from 'jszip'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Embeds the deck's typeface in the written package, which pptxgenjs cannot do.
|
|
5
|
+
* PowerPoint reads an embedded face as an Embedded OpenType 2.2 container
|
|
6
|
+
* around the TrueType or OpenType data, stored as `ppt/fonts/fontN.fntdata`
|
|
7
|
+
* and listed in `presentation.xml`. The header is built from the face's own
|
|
8
|
+
* `OS/2`, `head`, and `name` tables, laid out per the W3C EOT submission:
|
|
9
|
+
* https://www.w3.org/submissions/EOT/
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface EmbedFace {
|
|
13
|
+
readonly family: string
|
|
14
|
+
readonly weight: number
|
|
15
|
+
readonly style: 'normal' | 'italic'
|
|
16
|
+
/** As `deck.json` wrote it, which every notice names. */
|
|
17
|
+
readonly path: string
|
|
18
|
+
readonly bytes: Uint8Array
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FontNotice {
|
|
22
|
+
readonly path: string
|
|
23
|
+
readonly message: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type EotResult =
|
|
27
|
+
| { readonly status: 'embeddable'; readonly eot: Buffer }
|
|
28
|
+
| { readonly status: 'refused'; readonly message: string }
|
|
29
|
+
|
|
30
|
+
/** `fsType` bits from the OpenType `OS/2` table. */
|
|
31
|
+
const RESTRICTED = 0x0002
|
|
32
|
+
const BITMAP_ONLY = 0x0200
|
|
33
|
+
|
|
34
|
+
const SFNT_VERSIONS = new Set([0x00010000, 0x74727565, 0x4f54544f])
|
|
35
|
+
const WRAPPED: Readonly<Record<number, string>> = {
|
|
36
|
+
0x774f4632: 'woff2',
|
|
37
|
+
0x774f4646: 'woff',
|
|
38
|
+
0x74746366: 'a font collection',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const UNDECODABLE = 'it could not be decoded as a TrueType or OpenType font'
|
|
42
|
+
|
|
43
|
+
interface Table {
|
|
44
|
+
readonly offset: number
|
|
45
|
+
readonly length: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tables(font: Buffer): Map<string, Table> {
|
|
49
|
+
const found = new Map<string, Table>()
|
|
50
|
+
const count = font.readUInt16BE(4)
|
|
51
|
+
for (let index = 0; index < count; index += 1) {
|
|
52
|
+
const at = 12 + index * 16
|
|
53
|
+
const offset = font.readUInt32BE(at + 8)
|
|
54
|
+
const length = font.readUInt32BE(at + 12)
|
|
55
|
+
if (offset + length > font.length) throw new RangeError('table overruns')
|
|
56
|
+
found.set(font.toString('latin1', at, at + 4), { offset, length })
|
|
57
|
+
}
|
|
58
|
+
return found
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The Windows platform's strings, in UTF-16, preferring US English where a
|
|
63
|
+
* face carries several languages.
|
|
64
|
+
*/
|
|
65
|
+
function names(font: Buffer, table: Table): Map<number, Buffer> {
|
|
66
|
+
const found = new Map<number, { language: number; bytes: Buffer }>()
|
|
67
|
+
const count = font.readUInt16BE(table.offset + 2)
|
|
68
|
+
const strings = table.offset + font.readUInt16BE(table.offset + 4)
|
|
69
|
+
for (let index = 0; index < count; index += 1) {
|
|
70
|
+
const at = table.offset + 6 + index * 12
|
|
71
|
+
if (font.readUInt16BE(at) !== 3) continue
|
|
72
|
+
const language = font.readUInt16BE(at + 4)
|
|
73
|
+
const id = font.readUInt16BE(at + 6)
|
|
74
|
+
const length = font.readUInt16BE(at + 8)
|
|
75
|
+
const start = strings + font.readUInt16BE(at + 10)
|
|
76
|
+
if (start + length > font.length) throw new RangeError('name overruns')
|
|
77
|
+
const held = found.get(id)
|
|
78
|
+
if (held && (held.language === 0x0409 || language !== 0x0409)) continue
|
|
79
|
+
const bytes = Buffer.from(font.subarray(start, start + length)).swap16()
|
|
80
|
+
found.set(id, { language, bytes })
|
|
81
|
+
}
|
|
82
|
+
return new Map([...found].map(([id, { bytes }]) => [id, bytes]))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sized(bytes: Buffer = Buffer.alloc(0)): Buffer {
|
|
86
|
+
const size = Buffer.alloc(2)
|
|
87
|
+
size.writeUInt16LE(bytes.length)
|
|
88
|
+
return Buffer.concat([size, bytes])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function header(font: Buffer, os2: number, head: number): Buffer {
|
|
92
|
+
const fixed = Buffer.alloc(80)
|
|
93
|
+
fixed.writeUInt32LE(font.length, 4)
|
|
94
|
+
fixed.writeUInt32LE(0x00020002, 8)
|
|
95
|
+
font.copy(fixed, 16, os2 + 32, os2 + 42)
|
|
96
|
+
fixed.writeUInt8(1, 26)
|
|
97
|
+
fixed.writeUInt8(font.readUInt16BE(os2 + 62) & 1, 27)
|
|
98
|
+
fixed.writeUInt32LE(font.readUInt16BE(os2 + 4), 28)
|
|
99
|
+
fixed.writeUInt16LE(font.readUInt16BE(os2 + 8), 32)
|
|
100
|
+
fixed.writeUInt16LE(0x504c, 34)
|
|
101
|
+
for (let index = 0; index < 4; index += 1) {
|
|
102
|
+
fixed.writeUInt32LE(font.readUInt32BE(os2 + 42 + index * 4), 36 + index * 4)
|
|
103
|
+
}
|
|
104
|
+
for (let index = 0; index < 2; index += 1) {
|
|
105
|
+
fixed.writeUInt32LE(font.readUInt32BE(os2 + 78 + index * 4), 52 + index * 4)
|
|
106
|
+
}
|
|
107
|
+
fixed.writeUInt32LE(font.readUInt32BE(head + 8), 60)
|
|
108
|
+
return fixed
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The EOT container around one face, or the reason it may not travel. A face
|
|
113
|
+
* whose license restricts embedding or allows a bitmap only is refused, as is
|
|
114
|
+
* a variable face, which PowerPoint does not embed. Editable and print-preview
|
|
115
|
+
* embedding both pass, since the deck is meant to be edited.
|
|
116
|
+
*/
|
|
117
|
+
export function eotOf(bytes: Uint8Array): EotResult {
|
|
118
|
+
const font = Buffer.from(bytes)
|
|
119
|
+
const refused = (message: string): EotResult => ({
|
|
120
|
+
status: 'refused',
|
|
121
|
+
message,
|
|
122
|
+
})
|
|
123
|
+
if (font.length < 12) return refused(UNDECODABLE)
|
|
124
|
+
const version = font.readUInt32BE(0)
|
|
125
|
+
const wrapped = WRAPPED[version]
|
|
126
|
+
if (wrapped) {
|
|
127
|
+
return refused(
|
|
128
|
+
`it is ${wrapped}. Supply the TrueType or OpenType file instead`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
if (!SFNT_VERSIONS.has(version)) return refused(UNDECODABLE)
|
|
132
|
+
try {
|
|
133
|
+
const found = tables(font)
|
|
134
|
+
const os2 = found.get('OS/2')
|
|
135
|
+
const head = found.get('head')
|
|
136
|
+
const name = found.get('name')
|
|
137
|
+
// Version 0 of OS/2 stops before the code page ranges the header copies.
|
|
138
|
+
if (!os2 || os2.length < 86 || !head || head.length < 12 || !name) {
|
|
139
|
+
return refused(UNDECODABLE)
|
|
140
|
+
}
|
|
141
|
+
const fsType = font.readUInt16BE(os2.offset + 8)
|
|
142
|
+
if (fsType & RESTRICTED) {
|
|
143
|
+
return refused('its license forbids embedding (fsType restricted)')
|
|
144
|
+
}
|
|
145
|
+
if (fsType & BITMAP_ONLY) {
|
|
146
|
+
return refused('its license allows bitmap embedding only (fsType 0x0200)')
|
|
147
|
+
}
|
|
148
|
+
if (found.has('fvar')) {
|
|
149
|
+
return refused(
|
|
150
|
+
'it is a variable font, which PowerPoint does not embed. Supply a static instance',
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
const strings = names(font, name)
|
|
154
|
+
const padding = Buffer.alloc(2)
|
|
155
|
+
// RootStringCheckSum, EUDCCodePage, Padding6, SignatureSize, EUDCFlags,
|
|
156
|
+
// and EUDCFontSize, all zero for a face with no root string or EUDC data.
|
|
157
|
+
const tail = Buffer.alloc(20)
|
|
158
|
+
const eot = Buffer.concat([
|
|
159
|
+
header(font, os2.offset, head.offset),
|
|
160
|
+
padding,
|
|
161
|
+
sized(strings.get(1)),
|
|
162
|
+
padding,
|
|
163
|
+
sized(strings.get(2)),
|
|
164
|
+
padding,
|
|
165
|
+
sized(strings.get(5)),
|
|
166
|
+
padding,
|
|
167
|
+
sized(strings.get(4)),
|
|
168
|
+
padding,
|
|
169
|
+
sized(),
|
|
170
|
+
tail,
|
|
171
|
+
font,
|
|
172
|
+
])
|
|
173
|
+
eot.writeUInt32LE(eot.length, 0)
|
|
174
|
+
return { status: 'embeddable', eot }
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error instanceof RangeError) return refused(UNDECODABLE)
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type Slot = 'regular' | 'bold' | 'italic' | 'boldItalic'
|
|
182
|
+
|
|
183
|
+
/** The child order `<p:embeddedFont>` requires. */
|
|
184
|
+
const SLOTS: readonly Slot[] = ['regular', 'bold', 'italic', 'boldItalic']
|
|
185
|
+
|
|
186
|
+
/** PowerPoint keeps four faces a family, so a weight from 600 reads as bold. */
|
|
187
|
+
function slotOf(face: EmbedFace): Slot {
|
|
188
|
+
const isBold = face.weight >= 600
|
|
189
|
+
if (face.style === 'italic') return isBold ? 'boldItalic' : 'italic'
|
|
190
|
+
return isBold ? 'bold' : 'regular'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const FONT_RELATIONSHIP =
|
|
194
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/font'
|
|
195
|
+
const NOTES_SIZE = /<p:notesSz\b[^>]*\/>/
|
|
196
|
+
|
|
197
|
+
const escapeXml = (value: string): string =>
|
|
198
|
+
value
|
|
199
|
+
.replaceAll('&', '&')
|
|
200
|
+
.replaceAll('"', '"')
|
|
201
|
+
.replaceAll('<', '<')
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Embeds each face the deck lists and returns one notice per face left out.
|
|
205
|
+
* The font list goes after `<p:notesSz>`, where the schema orders it, and a
|
|
206
|
+
* package missing that anchor embeds nothing rather than placing it elsewhere.
|
|
207
|
+
*/
|
|
208
|
+
export async function embedFonts(
|
|
209
|
+
zip: JSZip,
|
|
210
|
+
faces: readonly EmbedFace[],
|
|
211
|
+
): Promise<FontNotice[]> {
|
|
212
|
+
const notices: FontNotice[] = []
|
|
213
|
+
const families = new Map<string, Map<Slot, number>>()
|
|
214
|
+
const parts: Buffer[] = []
|
|
215
|
+
|
|
216
|
+
for (const face of faces) {
|
|
217
|
+
const result = eotOf(face.bytes)
|
|
218
|
+
if (result.status === 'refused') {
|
|
219
|
+
notices.push({ path: face.path, message: result.message })
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
const slots = families.get(face.family) ?? new Map<Slot, number>()
|
|
223
|
+
const slot = slotOf(face)
|
|
224
|
+
if (slots.has(slot)) {
|
|
225
|
+
notices.push({
|
|
226
|
+
path: face.path,
|
|
227
|
+
message: `${face.family} already embeds a ${slot} face, and PowerPoint holds one per slot`,
|
|
228
|
+
})
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
parts.push(result.eot)
|
|
232
|
+
slots.set(slot, parts.length)
|
|
233
|
+
families.set(face.family, slots)
|
|
234
|
+
}
|
|
235
|
+
if (parts.length === 0) return notices
|
|
236
|
+
|
|
237
|
+
const presentationPath = 'ppt/presentation.xml'
|
|
238
|
+
const relsPath = 'ppt/_rels/presentation.xml.rels'
|
|
239
|
+
const typesPath = '[Content_Types].xml'
|
|
240
|
+
const [presentation, rels, types] = await Promise.all(
|
|
241
|
+
[presentationPath, relsPath, typesPath].map(
|
|
242
|
+
async (path) => (await zip.file(path)?.async('string')) ?? '',
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
if (!presentation || !NOTES_SIZE.test(presentation)) {
|
|
246
|
+
const message = 'the deck has no <p:notesSz> to anchor the font list after'
|
|
247
|
+
return [
|
|
248
|
+
...notices,
|
|
249
|
+
...faces
|
|
250
|
+
.filter((face) => !notices.some((notice) => notice.path === face.path))
|
|
251
|
+
.map((face) => ({ path: face.path, message })),
|
|
252
|
+
]
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const id = (part: number): string => `rIdCanonFont${part}`
|
|
256
|
+
parts.forEach((eot, index) => {
|
|
257
|
+
zip.file(`ppt/fonts/font${index + 1}.fntdata`, eot)
|
|
258
|
+
})
|
|
259
|
+
const relationships = parts
|
|
260
|
+
.map(
|
|
261
|
+
(_, index) =>
|
|
262
|
+
`<Relationship Id="${id(index + 1)}" Type="${FONT_RELATIONSHIP}" Target="fonts/font${index + 1}.fntdata"/>`,
|
|
263
|
+
)
|
|
264
|
+
.join('')
|
|
265
|
+
zip.file(
|
|
266
|
+
relsPath,
|
|
267
|
+
rels.replace('</Relationships>', `${relationships}</Relationships>`),
|
|
268
|
+
)
|
|
269
|
+
if (!types.includes('Extension="fntdata"')) {
|
|
270
|
+
zip.file(
|
|
271
|
+
typesPath,
|
|
272
|
+
types.replace(
|
|
273
|
+
/(<Types\b[^>]*>)/,
|
|
274
|
+
'$1<Default Extension="fntdata" ContentType="application/x-fontdata"/>',
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
const list = [...families]
|
|
279
|
+
.map(([family, slots]) => {
|
|
280
|
+
const children = SLOTS.flatMap((slot) => {
|
|
281
|
+
const part = slots.get(slot)
|
|
282
|
+
return part ? [`<p:${slot} r:id="${id(part)}"/>`] : []
|
|
283
|
+
})
|
|
284
|
+
return `<p:embeddedFont><p:font typeface="${escapeXml(family)}"/>${children.join('')}</p:embeddedFont>`
|
|
285
|
+
})
|
|
286
|
+
.join('')
|
|
287
|
+
zip.file(
|
|
288
|
+
presentationPath,
|
|
289
|
+
presentation
|
|
290
|
+
.replace(
|
|
291
|
+
NOTES_SIZE,
|
|
292
|
+
(anchor) => `${anchor}<p:embeddedFontLst>${list}</p:embeddedFontLst>`,
|
|
293
|
+
)
|
|
294
|
+
.replace('<p:presentation ', '<p:presentation embedTrueTypeFonts="1" '),
|
|
295
|
+
)
|
|
296
|
+
return notices
|
|
297
|
+
}
|