capture-flow 0.0.0-stage → 0.1.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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +238 -2
  3. package/bin/capture-flow.mjs +12 -0
  4. package/package.json +55 -4
  5. package/src/build.ts +186 -0
  6. package/src/capture.ts +77 -0
  7. package/src/cli.ts +139 -0
  8. package/src/index.ts +11 -0
  9. package/src/manifest.ts +98 -0
  10. package/src/render.ts +75 -0
  11. package/src/verify.ts +90 -0
  12. package/vendor/wajha/LICENSE +21 -0
  13. package/vendor/wajha/NOTICE +21 -0
  14. package/vendor/wajha/engine/background.ts +55 -0
  15. package/vendor/wajha/engine/bidi.ts +589 -0
  16. package/vendor/wajha/engine/cache.ts +96 -0
  17. package/vendor/wajha/engine/color.ts +125 -0
  18. package/vendor/wajha/engine/devices/android.ts +53 -0
  19. package/vendor/wajha/engine/devices/browser.ts +170 -0
  20. package/vendor/wajha/engine/devices/finish.ts +154 -0
  21. package/vendor/wajha/engine/devices/geometry.ts +326 -0
  22. package/vendor/wajha/engine/devices/index.ts +143 -0
  23. package/vendor/wajha/engine/devices/ipad.ts +21 -0
  24. package/vendor/wajha/engine/devices/iphone.ts +55 -0
  25. package/vendor/wajha/engine/devices/macbook.ts +187 -0
  26. package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
  27. package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
  28. package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
  29. package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
  30. package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
  31. package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
  32. package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
  33. package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
  34. package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
  35. package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
  36. package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
  37. package/vendor/wajha/engine/devices/mock/art.ts +557 -0
  38. package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
  39. package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
  40. package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
  41. package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
  42. package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
  43. package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
  44. package/vendor/wajha/engine/devices/paths.ts +113 -0
  45. package/vendor/wajha/engine/devices/screen.ts +75 -0
  46. package/vendor/wajha/engine/devices/shadow.ts +124 -0
  47. package/vendor/wajha/engine/devices/slab.ts +200 -0
  48. package/vendor/wajha/engine/devices/vision.ts +43 -0
  49. package/vendor/wajha/engine/devices/watch.ts +14 -0
  50. package/vendor/wajha/engine/geometry.ts +152 -0
  51. package/vendor/wajha/engine/images.ts +96 -0
  52. package/vendor/wajha/engine/index.ts +20 -0
  53. package/vendor/wajha/engine/linebreak.ts +226 -0
  54. package/vendor/wajha/engine/markup.ts +84 -0
  55. package/vendor/wajha/engine/mock.ts +158 -0
  56. package/vendor/wajha/engine/paint.ts +474 -0
  57. package/vendor/wajha/engine/patterns.ts +263 -0
  58. package/vendor/wajha/engine/render.ts +364 -0
  59. package/vendor/wajha/engine/shapes.ts +260 -0
  60. package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
  61. package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
  62. package/vendor/wajha/engine/text.ts +694 -0
  63. package/vendor/wajha/engine/types.ts +190 -0
  64. package/vendor/wajha/export/paths.ts +197 -0
  65. package/vendor/wajha/export/png.ts +223 -0
  66. package/vendor/wajha/export/types.ts +60 -0
  67. package/vendor/wajha/model/adapt.ts +889 -0
  68. package/vendor/wajha/model/catalog.ts +369 -0
  69. package/vendor/wajha/model/clone.ts +58 -0
  70. package/vendor/wajha/model/colorMath.ts +128 -0
  71. package/vendor/wajha/model/defaults.ts +386 -0
  72. package/vendor/wajha/model/geometry.ts +95 -0
  73. package/vendor/wajha/model/ids.ts +13 -0
  74. package/vendor/wajha/model/instantiate.ts +280 -0
  75. package/vendor/wajha/model/mirror.ts +43 -0
  76. package/vendor/wajha/model/resolve.ts +87 -0
  77. package/vendor/wajha/model/rules.ts +249 -0
  78. package/vendor/wajha/model/stub.ts +4 -0
  79. package/vendor/wajha/model/types.ts +456 -0
  80. package/vendor/wajha/model/typography.ts +28 -0
  81. package/vendor/wajha/model/validate.ts +917 -0
  82. package/vendor/wajha/templates/builtin/aurora.ts +163 -0
  83. package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
  84. package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
  85. package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
  86. package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
  87. package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
  88. package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
  89. package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
  90. package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
  91. package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
  92. package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
  93. package/vendor/wajha/templates/builtin/noor.ts +107 -0
  94. package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
  95. package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
  96. package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
  97. package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
  98. package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
  99. package/vendor/wajha/templates/builtin/sage.ts +94 -0
  100. package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
  101. package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
  102. package/vendor/wajha/templates/index.ts +55 -0
  103. package/vendor/wajha/templates/kit.ts +761 -0
  104. package/vendor/wajha/ui/sortShots.ts +239 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Template <-> project conversion. Owned by the model/store work package.
3
+ *
4
+ * Templates carry one layout per aspect class, each authored on a reference
5
+ * canvas: tall = the iPhone design canvas, tablet = iPad, wide = Mac. A new
6
+ * project stores only what the editor cannot derive (ARCHITECTURE 6.1): the
7
+ * primary family's layout, plus the template's own layout for any other
8
+ * aspect class it designs. Every other family follows the primary family
9
+ * through resolveLayout until the user edits it.
10
+ */
11
+ import { adaptLayout } from './adapt';
12
+ import { FAMILIES } from './catalog';
13
+ import { deepClone } from './clone';
14
+ import { DEFAULT_FAMILY, DEFAULT_LANGUAGE, DEFAULT_PROJECT_NAME, defaultExportSettings, newSlide } from './defaults';
15
+ import { newId } from './ids';
16
+ import { resolveLayout } from './resolve';
17
+ import { LIMITS, cleanName, cleanText, isTemplateId, uniqueFamilies, uniqueLanguages } from './rules';
18
+ import type {
19
+ AspectClass,
20
+ AssetInfo,
21
+ Background,
22
+ Fill,
23
+ FamilyId,
24
+ LangCode,
25
+ Project,
26
+ Slide,
27
+ SlideLayout,
28
+ Template,
29
+ TemplateCategory,
30
+ TemplateSlide,
31
+ Theme,
32
+ } from './types';
33
+
34
+ export interface NewProjectOptions {
35
+ name: string;
36
+ families: FamilyId[];
37
+ languages: LangCode[];
38
+ }
39
+
40
+ /** The family whose design canvas a template's class layout is authored on. */
41
+ export const REFERENCE_FAMILY: Readonly<Record<AspectClass, FamilyId>> = { tall: 'iphone', tablet: 'ipad', wide: 'mac' };
42
+
43
+ const ASPECT_CLASSES: readonly AspectClass[] = ['tall', 'tablet', 'wide'];
44
+
45
+ export const TEMPLATE_CATEGORIES: readonly TemplateCategory[] = ['minimal', 'bold', 'gradient', 'dark', 'playful', 'panorama', 'elegant'];
46
+
47
+ /** Copy of `layout` with element ids mapped through `ids` (one fresh id per old id, unique within the layout). */
48
+ function remapIds(layout: SlideLayout, ids: Map<string, string>): SlideLayout {
49
+ const used = new Set<string>();
50
+ const elements = (Array.isArray(layout.elements) ? layout.elements : []).map((el) => {
51
+ const copy = deepClone(el);
52
+ let id = ids.get(el.id);
53
+ if (!id || used.has(id)) {
54
+ id = newId('e');
55
+ if (!ids.has(el.id)) ids.set(el.id, id);
56
+ }
57
+ used.add(id);
58
+ copy.id = id;
59
+ return copy;
60
+ });
61
+ return { background: layout.background ? deepClone(layout.background) : null, elements };
62
+ }
63
+
64
+ /** The script subtag of a language code ("hans" in "zh-Hans"), lower case, or undefined. */
65
+ const scriptOf = (code: LangCode) => code.split('-').find((part, i) => i > 0 && /^[A-Za-z]{4}$/.test(part))?.toLowerCase();
66
+
67
+ /**
68
+ * The template's own copy for `lang`: the same language, else a regional
69
+ * variant of it ("ar-SA" <- "ar" or "ar-EG"), but never one in another
70
+ * script ("zh-Hant" does not take "zh-Hans"). Undefined when it has none.
71
+ */
72
+ export function sameLanguageTexts(texts: Record<LangCode, Record<string, string>>, lang: LangCode): Record<string, string> | undefined {
73
+ const own = (k: string) => (Object.prototype.hasOwnProperty.call(texts, k) ? texts[k] : undefined);
74
+ const exact = own(lang);
75
+ if (exact) return exact;
76
+ const primary = lang.split('-')[0];
77
+ const bare = own(primary);
78
+ if (bare) return bare;
79
+ const script = scriptOf(lang);
80
+ const variant = Object.keys(texts).find((k) => {
81
+ if (k.split('-')[0] !== primary) return false;
82
+ const other = scriptOf(k);
83
+ return !script || !other || script === other;
84
+ });
85
+ return variant ? texts[variant] : undefined;
86
+ }
87
+
88
+ /**
89
+ * Sample copy for `lang`: the template's own copy for it (see
90
+ * sameLanguageTexts), else "en-US", else any English, else anything.
91
+ */
92
+ export function pickTexts(texts: Record<LangCode, Record<string, string>>, lang: LangCode): Record<string, string> {
93
+ const same = sameLanguageTexts(texts, lang);
94
+ if (same) return same;
95
+ const keys = Object.keys(texts);
96
+ const en = (Object.prototype.hasOwnProperty.call(texts, DEFAULT_LANGUAGE) ? texts[DEFAULT_LANGUAGE] : undefined) ?? keys.filter((k) => k.split('-')[0] === 'en').map((k) => texts[k])[0];
97
+ if (en) return en;
98
+ return keys.length ? texts[keys[0]] : {};
99
+ }
100
+
101
+ /** Layout for `family` from a template slide whose class layouts already carry the project's element ids. */
102
+ function layoutForFamily(classLayouts: Partial<Record<AspectClass, SlideLayout>>, family: FamilyId): SlideLayout {
103
+ const info = FAMILIES[family];
104
+ const ref = REFERENCE_FAMILY[info.aspectClass];
105
+ const own = classLayouts[info.aspectClass];
106
+ if (own) return family === ref ? deepClone(own) : adaptLayout(own, FAMILIES[ref].design, info.design, family);
107
+ const tall = classLayouts.tall;
108
+ if (!tall) return { background: null, elements: [] };
109
+ return adaptLayout(tall, FAMILIES.iphone.design, info.design, family);
110
+ }
111
+
112
+ function slideFromTemplate(ts: TemplateSlide, families: FamilyId[], languages: LangCode[]): Slide {
113
+ // One id map per slide: the same template element gets the same new id in every class layout.
114
+ const ids = new Map<string, string>();
115
+ const classLayouts: Partial<Record<AspectClass, SlideLayout>> = {};
116
+ for (const cls of ASPECT_CLASSES) {
117
+ const l = ts.layouts?.[cls];
118
+ if (l) classLayouts[cls] = remapIds(l, ids);
119
+ }
120
+ if (!classLayouts.tall) {
121
+ // Validated templates always have one; derive it rather than fail on hand-made data.
122
+ const cls = ASPECT_CLASSES.find((k) => classLayouts[k]);
123
+ if (cls) classLayouts.tall = adaptLayout(classLayouts[cls] as SlideLayout, FAMILIES[REFERENCE_FAMILY[cls]].design, FAMILIES.iphone.design, 'iphone');
124
+ }
125
+ // Only what cannot be derived: other families follow the primary one until edited.
126
+ const primary = families[0];
127
+ const primaryClass = FAMILIES[primary].aspectClass;
128
+ const layouts: Slide['layouts'] = {};
129
+ for (const f of families) {
130
+ const cls = FAMILIES[f].aspectClass;
131
+ if (f === primary || (cls !== primaryClass && ts.layouts?.[cls])) layouts[f] = layoutForFamily(classLayouts, f);
132
+ }
133
+ // The base language always shows sample copy. Other languages get the
134
+ // template's translation, or nothing: they fall back to the base text and
135
+ // count as untranslated instead of shipping another language's samples.
136
+ const texts: Slide['texts'] = {};
137
+ const source = ts.texts ?? {};
138
+ languages.forEach((lang, i) => {
139
+ const copy = i === 0 ? pickTexts(source, lang) : sameLanguageTexts(source, lang);
140
+ if (copy) texts[lang] = deepClone(copy);
141
+ });
142
+ return { id: newId('s'), texts, shots: {}, layouts };
143
+ }
144
+
145
+ /** New project from a template: copies theme, slides, sample texts and assets. */
146
+ export function projectFromTemplate(template: Template, opts: NewProjectOptions): Project {
147
+ const families = uniqueFamilies(opts?.families);
148
+ if (!families.length) families.push(DEFAULT_FAMILY);
149
+ const languages = uniqueLanguages(opts?.languages);
150
+ if (!languages.length) languages.push(DEFAULT_LANGUAGE);
151
+ const theme = deepClone(template.theme);
152
+ const slides = (Array.isArray(template.slides) ? template.slides : [])
153
+ .slice(0, LIMITS.templateSlides)
154
+ .map((ts) => slideFromTemplate(ts, families, languages));
155
+ if (!slides.length) slides.push(newSlide(families, theme, languages));
156
+ const name = (typeof opts?.name === 'string' ? cleanName(opts.name) : '') || cleanName(template.name ?? '') || DEFAULT_PROJECT_NAME;
157
+ const now = Date.now();
158
+ return {
159
+ format: 'wajha.project',
160
+ version: 1,
161
+ id: newId(),
162
+ name,
163
+ createdAt: now,
164
+ updatedAt: now,
165
+ templateId: isTemplateId(template.id) ? template.id : null,
166
+ theme,
167
+ families,
168
+ languages,
169
+ slides,
170
+ assets: deepClone(template.assets ?? {}),
171
+ export: defaultExportSettings(),
172
+ };
173
+ }
174
+
175
+ export interface TemplateMeta {
176
+ id: string;
177
+ name: string;
178
+ author: string;
179
+ description: string;
180
+ category: TemplateCategory;
181
+ tags: string[];
182
+ }
183
+
184
+ /** kebab-case id from `id`, else from `name`, else a fresh "t..." id. */
185
+ export function templateIdFrom(id: string, name: string): string {
186
+ if (isTemplateId(id)) return id;
187
+ for (const raw of [id, name]) {
188
+ if (typeof raw !== 'string') continue;
189
+ const slug = raw
190
+ .normalize('NFKD')
191
+ // Accents become separate combining marks under NFKD; drop them so "é" slugs to "e".
192
+ .replace(/[\u0300-\u036f]/g, '')
193
+ .toLowerCase()
194
+ .replace(/[^a-z0-9]+/g, '-')
195
+ .replace(/^-+|-+$/g, '')
196
+ .slice(0, LIMITS.id)
197
+ .replace(/-+$/g, '');
198
+ if (isTemplateId(slug)) return slug;
199
+ }
200
+ return newId('t');
201
+ }
202
+
203
+ function fillAssets(fill: Fill | null | undefined, into: Set<string>): void {
204
+ if (fill && fill.type === 'image') into.add(fill.assetId);
205
+ }
206
+
207
+ function backgroundAssets(bg: Background | null | undefined, into: Set<string>): void {
208
+ if (bg) fillAssets(bg.fill, into);
209
+ }
210
+
211
+ /** Asset ids a design (theme + layouts) references; screenshots are not design. */
212
+ export function designAssets(theme: Theme, layouts: Iterable<SlideLayout>): Set<string> {
213
+ const ids = new Set<string>();
214
+ backgroundAssets(theme.background, ids);
215
+ for (const l of layouts) {
216
+ backgroundAssets(l.background, ids);
217
+ for (const el of l.elements) {
218
+ if (el.kind === 'image') ids.add(el.assetId);
219
+ else if (el.kind === 'shape') fillAssets(el.fill, ids);
220
+ }
221
+ }
222
+ return ids;
223
+ }
224
+
225
+ function templateSlide(project: Project, slide: Slide): TemplateSlide {
226
+ const base = project.languages[0] ?? DEFAULT_LANGUAGE;
227
+ const sample = deepClone(slide.texts[base] ?? {});
228
+ const texts: TemplateSlide['texts'] = { [DEFAULT_LANGUAGE]: sample };
229
+ if (base !== DEFAULT_LANGUAGE) texts[base] = deepClone(sample);
230
+
231
+ const byClass: Partial<Record<AspectClass, SlideLayout>> = {};
232
+ for (const f of project.families) {
233
+ const info = FAMILIES[f];
234
+ if (!info || byClass[info.aspectClass]) continue;
235
+ const ref = REFERENCE_FAMILY[info.aspectClass];
236
+ const layout = resolveLayout(project, slide, f);
237
+ // Store it as authored on the reference canvas so instantiation reads it the same way.
238
+ byClass[info.aspectClass] = f === ref ? deepClone(layout) : adaptLayout(layout, info.design, FAMILIES[ref].design, ref);
239
+ }
240
+ if (!byClass.tall) {
241
+ const cls = ASPECT_CLASSES.find((k) => byClass[k]);
242
+ const from = cls ? (byClass[cls] as SlideLayout) : resolveLayout(project, slide, DEFAULT_FAMILY);
243
+ byClass.tall = cls ? adaptLayout(from, FAMILIES[REFERENCE_FAMILY[cls]].design, FAMILIES.iphone.design, 'iphone') : deepClone(from);
244
+ }
245
+ const layouts: TemplateSlide['layouts'] = { tall: byClass.tall };
246
+ if (byClass.tablet) layouts.tablet = byClass.tablet;
247
+ if (byClass.wide) layouts.wide = byClass.wide;
248
+ return { texts, layouts };
249
+ }
250
+
251
+ /** "Save as template": keeps design, drops screenshots, keeps base-language texts as samples. */
252
+ export function templateFromProject(project: Project, meta: TemplateMeta): Template {
253
+ const slides = project.slides.slice(0, LIMITS.templateSlides).map((s) => templateSlide(project, s));
254
+ const theme = deepClone(project.theme);
255
+ const used = designAssets(theme, slides.flatMap((s) => [s.layouts.tall, s.layouts.tablet, s.layouts.wide].filter((l): l is SlideLayout => !!l)));
256
+ const assets: Record<string, AssetInfo> = {};
257
+ for (const id of used) {
258
+ const info = project.assets[id];
259
+ if (info) assets[id] = deepClone(info);
260
+ }
261
+ const tags: string[] = [];
262
+ for (const t of Array.isArray(meta?.tags) ? meta.tags : []) {
263
+ const tag = typeof t === 'string' ? cleanName(t, LIMITS.tag).toLowerCase() : '';
264
+ if (tag && !tags.includes(tag) && tags.length < LIMITS.tags) tags.push(tag);
265
+ }
266
+ const name = (typeof meta?.name === 'string' ? cleanName(meta.name) : '') || project.name || DEFAULT_PROJECT_NAME;
267
+ return {
268
+ format: 'wajha.template',
269
+ version: 1,
270
+ id: templateIdFrom(meta?.id, name),
271
+ name,
272
+ author: typeof meta?.author === 'string' ? cleanName(meta.author) : '',
273
+ description: typeof meta?.description === 'string' ? cleanText(meta.description) : '',
274
+ category: TEMPLATE_CATEGORIES.includes(meta?.category) ? meta.category : 'minimal',
275
+ tags,
276
+ theme,
277
+ slides,
278
+ assets,
279
+ };
280
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Right-to-left mirroring. An Arabic or Hebrew store listing reads from the
3
+ * right, so a slide whose phone sits on the right in English puts it on the
4
+ * left: every element moves to the other side of the slide's centre line
5
+ * and leans the other way. Nothing is stored mirrored; renderers mirror the
6
+ * one shared layout when they draw a mirrored language, and the editor maps
7
+ * its edits back. Mirroring twice gives the original back.
8
+ */
9
+ import { isRtl } from './catalog';
10
+ import type { LangCode, Project, Shadow, SlideElement, SlideLayout } from './types';
11
+
12
+ /** Whether slides in `lang` are drawn mirrored: right-to-left languages, unless the project turned it off. */
13
+ export function mirrorsLayout(project: Pick<Project, 'mirrorRtl'> | null | undefined, lang: LangCode): boolean {
14
+ return isRtl(lang) && project?.mirrorRtl !== false;
15
+ }
16
+
17
+ const flipX = (x: unknown) => (typeof x === 'number' ? 1 - x : x);
18
+ const flipAngle = (a: unknown) => (typeof a === 'number' && a !== 0 ? -a : a);
19
+ const flipShadow = (s: Shadow | null): Shadow | null => (s && typeof s === 'object' && typeof s.x === 'number' && s.x !== 0 ? { ...s, x: -s.x } : s);
20
+
21
+ /** The element on the other side of the slide: x mirrored, rotation and shadow offsets reversed. */
22
+ export function mirrorElement<T extends SlideElement>(el: T): T {
23
+ const out = { ...el, x: flipX(el.x), rotation: flipAngle(el.rotation) } as T;
24
+ if (out.kind === 'text') {
25
+ if (out.style && typeof out.style === 'object') out.style = { ...out.style, shadow: flipShadow(out.style.shadow) };
26
+ } else if (out.kind !== 'shape' && 'shadow' in out) {
27
+ out.shadow = flipShadow(out.shadow);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /** The layout with every element mirrored (the background is mirrored by the painter). */
33
+ export function mirrorLayout(layout: SlideLayout): SlideLayout {
34
+ return { ...layout, elements: layout.elements.map(mirrorElement) };
35
+ }
36
+
37
+ /** Mirrors the position and angle of an element patch, to store an edit made on a mirrored slide. */
38
+ export function mirrorPatch<P extends { x?: number; rotation?: number }>(patch: P): P {
39
+ const out = { ...patch };
40
+ if (typeof out.x === 'number') out.x = 1 - out.x;
41
+ if (typeof out.rotation === 'number' && out.rotation !== 0) out.rotation = -out.rotation;
42
+ return out;
43
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Read-side helpers that apply fallbacks. `resolveLayout` is owned by the
3
+ * model/store work package; the text and screenshot fallbacks are final.
4
+ */
5
+ import { adaptLayout } from './adapt';
6
+ import { FAMILIES, FAMILY_ORDER } from './catalog';
7
+ import { defaultLayout } from './defaults';
8
+ import type { FamilyId, LangCode, Project, Slide, SlideLayout, Theme } from './types';
9
+
10
+ interface CachedLayout {
11
+ layout: SlideLayout;
12
+ /** project.families when computed: the primary family picks the source. */
13
+ families: string;
14
+ /** Theme the default layout was built with; null when the layout does not depend on it. */
15
+ theme: Theme | null;
16
+ }
17
+
18
+ /**
19
+ * Derived layouts per slide object. immer gives an edited slide a new
20
+ * identity and keeps unchanged ones, so entries go stale exactly when they
21
+ * should and are collected with the slides they belong to.
22
+ */
23
+ const cache = new WeakMap<Slide, Map<FamilyId, CachedLayout>>();
24
+
25
+ const EMPTY_LAYOUT: SlideLayout = Object.freeze({ background: null, elements: Object.freeze([]) as unknown as SlideLayout['elements'] });
26
+
27
+ /** The family whose stored layout `family` derives from: the primary family, else any family with one. */
28
+ export function layoutSource(project: Project, slide: Slide, family: FamilyId): FamilyId | null {
29
+ const layouts = slide.layouts ?? {};
30
+ if (layouts[family]) return family;
31
+ const primary = project.families[0];
32
+ if (primary && layouts[primary]) return primary;
33
+ for (const f of project.families) if (layouts[f]) return f;
34
+ for (const f of FAMILY_ORDER) if (layouts[f]) return f;
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * The layout for `family`: stored layout, else adapted from the primary
40
+ * family's layout, else a default layout. The result is shared and cached:
41
+ * treat it as read-only and copy it before editing. The cache assumes slides
42
+ * are immutable (an edit makes a new slide object, as immer does).
43
+ */
44
+ export function resolveLayout(project: Project, slide: Slide, family: FamilyId): SlideLayout {
45
+ const stored = slide.layouts?.[family];
46
+ if (stored) return stored;
47
+ if (!FAMILIES[family]) return EMPTY_LAYOUT;
48
+
49
+ const families = project.families.join(',');
50
+ let byFamily = cache.get(slide);
51
+ const hit = byFamily?.get(family);
52
+ if (hit && hit.families === families && (hit.theme === null || hit.theme === project.theme)) return hit.layout;
53
+
54
+ const source = layoutSource(project, slide, family);
55
+ let entry: CachedLayout;
56
+ if (source && source !== family) {
57
+ const from = slide.layouts[source] as SlideLayout;
58
+ entry = { layout: adaptLayout(from, FAMILIES[source].design, FAMILIES[family].design, family), families, theme: null };
59
+ } else {
60
+ entry = { layout: defaultLayout(family, project.theme), families, theme: project.theme };
61
+ }
62
+ if (!byFamily) {
63
+ byFamily = new Map();
64
+ cache.set(slide, byFamily);
65
+ }
66
+ byFamily.set(family, entry);
67
+ return entry.layout;
68
+ }
69
+
70
+ /** `record[key]` when it is the record's own entry: keys come from data, and a plain object inherits "toString" and friends. */
71
+ function ownEntry<T>(record: Record<string, T> | undefined, key: string): T | undefined {
72
+ return record && Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined;
73
+ }
74
+
75
+ /** Text for key in `lang`, falling back to the base language, then "". */
76
+ export function resolveText(slide: Slide, key: string, lang: LangCode, baseLang: LangCode): string {
77
+ const own = ownEntry(ownEntry(slide.texts, lang), key);
78
+ if (own !== undefined && own !== '') return own;
79
+ return ownEntry(ownEntry(slide.texts, baseLang), key) ?? '';
80
+ }
81
+
82
+ /** Asset id for family/slot in `lang`, falling back to the base language, then null. */
83
+ export function resolveShot(slide: Slide, family: FamilyId, slot: string, lang: LangCode, baseLang: LangCode): string | null {
84
+ const bySlot = ownEntry(ownEntry(slide.shots, family), slot);
85
+ if (!bySlot) return null;
86
+ return ownEntry(bySlot, lang) ?? ownEntry(bySlot, baseLang) ?? null;
87
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Rules every persisted value follows: identifier patterns, string and array
3
+ * limits, numeric ranges. The validators enforce them on untrusted files and
4
+ * the editor store applies the same rules to user edits, so anything the UI
5
+ * can produce is something the loader accepts.
6
+ */
7
+ import { FAMILIES } from './catalog';
8
+ import type { FamilyId, LangCode } from './types';
9
+
10
+ /** Project, slide and element ids (path-safe; project ids name folders). */
11
+ export const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
12
+ /** Template ids: kebab-case, e.g. "sunset-glow" (and no Windows device name, see isTemplateId). */
13
+ export const TEMPLATE_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
14
+ /** Names Windows reserves for devices in every folder, whatever the case or extension. */
15
+ const WINDOWS_DEVICE_RE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
16
+ /** "<sha256 hex>.<ext>" */
17
+ export const ASSET_ID_RE = /^[0-9a-f]{64}\.(png|jpg|webp)$/;
18
+ export const COLOR_RE = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
19
+ /** BCP-47 shaped language codes: "en-US", "zh-Hans", "es-419", "fil". */
20
+ export const LANG_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
21
+ /** Text keys ("headline") and screenshot slots ("main"). */
22
+ export const KEY_RE = /^[A-Za-z0-9_.-]{1,40}$/;
23
+
24
+ /**
25
+ * Keys that must never become own properties of objects we build: every
26
+ * member plain objects inherit, because a lookup such as `texts[lang][key]`
27
+ * would find the inherited one ("toString" reads a function, "__proto__"
28
+ * swaps the prototype on assignment).
29
+ */
30
+ const FORBIDDEN_KEYS: ReadonlySet<string> = new Set([...Object.getOwnPropertyNames(Object.prototype), 'prototype']);
31
+
32
+ export const LIMITS = {
33
+ /** Project, template, asset, element and author names. */
34
+ name: 200,
35
+ /** Slide texts and descriptions. */
36
+ text: 2000,
37
+ font: 100,
38
+ tag: 40,
39
+ path: 300,
40
+ date: 64,
41
+ langCode: 35,
42
+ id: 64,
43
+ slides: 100,
44
+ templateSlides: 10,
45
+ elements: 200,
46
+ palette: 32,
47
+ stops: 16,
48
+ meshPoints: 16,
49
+ tags: 20,
50
+ languages: 100,
51
+ textKeys: 100,
52
+ slots: 20,
53
+ assets: 5000,
54
+ fonts: 24,
55
+ galleryTemplates: 1000,
56
+ galleryAssets: 200,
57
+ } as const;
58
+
59
+ export interface Range {
60
+ min: number;
61
+ max: number;
62
+ /** Round to an integer after clamping. */
63
+ int?: boolean;
64
+ }
65
+
66
+ /** Sane ranges for every number in the model. Lengths are fractions of u. */
67
+ export const RANGES = {
68
+ position: { min: -2, max: 3 },
69
+ rotation: { min: -360, max: 360 },
70
+ unit: { min: 0, max: 1 },
71
+ deviceWidth: { min: 0.02, max: 4 },
72
+ textWidth: { min: 0.02, max: 6 },
73
+ shapeSize: { min: 0.001, max: 8 },
74
+ imageWidth: { min: 0.01, max: 8 },
75
+ cornerRadius: { min: 0, max: 0.5 },
76
+ shapeRadius: { min: 0, max: 1 },
77
+ stroke: { min: 0, max: 2 },
78
+ blur: { min: 0, max: 2 },
79
+ shadowOffset: { min: -2, max: 2 },
80
+ fontSize: { min: 0.004, max: 1 },
81
+ fontWeight: { min: 1, max: 1000, int: true },
82
+ lineHeight: { min: 0.5, max: 4 },
83
+ /** Per-language factor on every text's line height (model/typography.ts). */
84
+ lineSpacing: { min: 0.8, max: 2 },
85
+ letterSpacing: { min: -0.5, max: 2 },
86
+ maxLines: { min: 0, max: 50, int: true },
87
+ seed: { min: 0, max: 2147483647, int: true },
88
+ angle: { min: -360, max: 360 },
89
+ gradientCenter: { min: -2, max: 3 },
90
+ gradientRadius: { min: 0.01, max: 4 },
91
+ patternScale: { min: 0.002, max: 2 },
92
+ imageBlur: { min: 0, max: 1 },
93
+ pixels: { min: 1, max: 100000, int: true },
94
+ bytes: { min: 0, max: Number.MAX_SAFE_INTEGER, int: true },
95
+ timestamp: { min: 0, max: 8.64e15, int: true },
96
+ jpegQuality: { min: 0.5, max: 1 },
97
+ galleryVersion: { min: 0, max: 1e9, int: true },
98
+ } as const satisfies Record<string, Range>;
99
+
100
+ /** Clamps into the range; NaN becomes the minimum so the result is always usable. */
101
+ export function clampToRange(v: number, r: Range): number {
102
+ let x = Number.isNaN(v) ? r.min : v < r.min ? r.min : v > r.max ? r.max : v;
103
+ if (r.int) x = Math.round(x);
104
+ return x;
105
+ }
106
+
107
+ /** A catalog family id (own keys of FAMILIES only). */
108
+ export function isFamilyId(v: unknown): v is FamilyId {
109
+ return typeof v === 'string' && Object.prototype.hasOwnProperty.call(FAMILIES, v);
110
+ }
111
+
112
+ /** A BCP-47 shaped language code of sane length. */
113
+ export function isLangCode(v: unknown): v is LangCode {
114
+ return typeof v === 'string' && v.length <= LIMITS.langCode && LANG_RE.test(v);
115
+ }
116
+
117
+ /** A record key we accept for text keys and screenshot slots. */
118
+ export function isSafeKey(v: unknown): v is string {
119
+ return typeof v === 'string' && v.length <= 40 && KEY_RE.test(v) && !FORBIDDEN_KEYS.has(v);
120
+ }
121
+
122
+ /** A project, slide or element id. */
123
+ export function isId(v: unknown): v is string {
124
+ return typeof v === 'string' && v.length <= LIMITS.id && ID_RE.test(v);
125
+ }
126
+
127
+ /** "con", "aux", "nul", "prn", "com1", "lpt1"...: Windows cannot create a file or folder with that name. */
128
+ export function isWindowsDeviceName(v: string): boolean {
129
+ return WINDOWS_DEVICE_RE.test(v);
130
+ }
131
+
132
+ /**
133
+ * A kebab-case template id of at most 64 characters. Template ids name their
134
+ * folder (templates/<id>/), so device names Windows reserves are refused
135
+ * everywhere: a template made on a Mac must open on Windows too.
136
+ */
137
+ export function isTemplateId(v: unknown): v is string {
138
+ return typeof v === 'string' && v.length <= LIMITS.id && TEMPLATE_ID_RE.test(v) && !isWindowsDeviceName(v);
139
+ }
140
+
141
+ /** A content-addressed asset id, "<sha256 hex>.<png|jpg|webp>". */
142
+ const FONT_FILE = /^[0-9a-f]{64}\.(ttf|otf|woff|woff2)$/;
143
+
144
+ /** "<64 hex>.<ttf|otf|woff|woff2>", the only file names allowed in fonts folders. */
145
+ export function isFontFile(v: unknown): v is string {
146
+ return typeof v === 'string' && FONT_FILE.test(v);
147
+ }
148
+
149
+ export function isAssetId(v: unknown): v is string {
150
+ return typeof v === 'string' && v.length <= 69 && ASSET_ID_RE.test(v);
151
+ }
152
+
153
+ /**
154
+ * "#RRGGBB" / "#RRGGBBAA" in lower case. The short forms "#RGB" and "#RGBA"
155
+ * are expanded because hand-written gallery JSON uses them; anything else is null.
156
+ */
157
+ export function normalizeColor(v: unknown): string | null {
158
+ if (typeof v !== 'string' || v.length > 9) return null;
159
+ if (COLOR_RE.test(v)) return v.toLowerCase();
160
+ if (/^#[0-9a-fA-F]{3,4}$/.test(v)) {
161
+ return `#${[...v.slice(1)].map((c) => c + c).join('')}`.toLowerCase();
162
+ }
163
+ return null;
164
+ }
165
+
166
+ const isHighSurrogate = (c: number) => c >= 0xd800 && c <= 0xdbff;
167
+ const isLowSurrogate = (c: number) => c >= 0xdc00 && c <= 0xdfff;
168
+
169
+ /**
170
+ * Keeps at most `max` code points (what storage counts as "characters") and
171
+ * never splits a surrogate pair, so emoji and astral scripts survive.
172
+ */
173
+ export function truncate(s: string, max: number): string {
174
+ if (s.length <= max) return s;
175
+ let i = 0;
176
+ let n = 0;
177
+ while (i < s.length && n < max) {
178
+ i += isHighSurrogate(s.charCodeAt(i)) && isLowSurrogate(s.charCodeAt(i + 1)) ? 2 : 1;
179
+ n++;
180
+ }
181
+ return s.slice(0, i);
182
+ }
183
+
184
+ // C0 and C1 controls (incl. DEL); names are single-line.
185
+ // eslint-disable-next-line no-control-regex
186
+ const NAME_CONTROLS = /[\u0000-\u001f\u007f-\u009f]/g;
187
+ // Texts keep line breaks and tabs; bidi marks (U+200E, U+2066...) are not controls and stay.
188
+ // eslint-disable-next-line no-control-regex
189
+ const TEXT_CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
190
+ // A surrogate pair, or a lone half (no lookbehind: WKWebView before Safari 16.4 cannot parse it).
191
+ const SURROGATES = /[\ud800-\udbff][\udc00-\udfff]|[\ud800-\udfff]/g;
192
+
193
+ /**
194
+ * Replaces unpaired surrogates with U+FFFD. They are not text: file names,
195
+ * URI encoding and the Rust side all refuse them, so a stored name with one
196
+ * would break every export path built from it.
197
+ */
198
+ export function wellFormed(s: string): string {
199
+ return s.replace(SURROGATES, (m) => (m.length === 2 ? m : '\ufffd'));
200
+ }
201
+
202
+ /** Single-line name: no control characters or unpaired surrogates, trimmed, length-limited. */
203
+ export function cleanName(s: string, max: number = LIMITS.name): string {
204
+ // Cut before the regex work so a huge hostile string costs nothing.
205
+ const head = s.length > max * 4 + 64 ? s.slice(0, max * 4 + 64) : s;
206
+ return truncate(wellFormed(head).replace(NAME_CONTROLS, ' ').replace(/\s+/g, ' ').trim(), max);
207
+ }
208
+
209
+ /** Slide text: CRLF/CR become LF, other controls and unpaired surrogates are removed or replaced, length-limited. */
210
+ export function cleanText(s: string, max: number = LIMITS.text): string {
211
+ const head = s.length > max * 2 + 64 ? s.slice(0, max * 2 + 64) : s;
212
+ return truncate(wellFormed(head).replace(/\r\n?/g, '\n').replace(TEXT_CONTROLS, ''), max);
213
+ }
214
+
215
+ /**
216
+ * CSS family name safe to interpolate into a canvas font string
217
+ * (`800 40px "<font>", ...`): quotes, backslashes and controls removed.
218
+ */
219
+ export function cleanFontName(s: string, fallback: string): string {
220
+ const head = s.length > LIMITS.font * 2 ? s.slice(0, LIMITS.font * 2) : s;
221
+ const out = truncate(wellFormed(head).replace(NAME_CONTROLS, '').replace(/["\\;{}<>]/g, '').replace(/\s+/g, ' ').trim(), LIMITS.font);
222
+ return out === '' ? fallback : out;
223
+ }
224
+
225
+ /** Valid catalog families in their given order, without duplicates. */
226
+ export function uniqueFamilies(list: readonly unknown[] | null | undefined): FamilyId[] {
227
+ const out: FamilyId[] = [];
228
+ if (!Array.isArray(list)) return out;
229
+ for (const f of list) if (isFamilyId(f) && !out.includes(f)) out.push(f);
230
+ return out;
231
+ }
232
+
233
+ /** Valid language codes in their given order, without duplicates, at most LIMITS.languages. */
234
+ export function uniqueLanguages(list: readonly unknown[] | null | undefined): LangCode[] {
235
+ const out: LangCode[] = [];
236
+ if (!Array.isArray(list)) return out;
237
+ for (const l of list) {
238
+ if (out.length >= LIMITS.languages) break;
239
+ if (isLangCode(l) && !out.includes(l)) out.push(l);
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /** Relative gallery path: "templates/sunset-glow/template.json". No "..", no leading "/", no schemes. */
245
+ export function isSafeRelPath(v: unknown): v is string {
246
+ if (typeof v !== 'string' || v.length === 0 || v.length > LIMITS.path) return false;
247
+ if (!/^[A-Za-z0-9._\-/]+$/.test(v)) return false;
248
+ return v.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
249
+ }
@@ -0,0 +1,4 @@
1
+ /** Placeholder thrown by functions that are declared but not implemented yet. */
2
+ export function notImplemented(name: string): never {
3
+ throw new Error(`${name} is not implemented yet`);
4
+ }