@erclx/canon 5.17.0 → 5.19.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 (51) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
  4. package/claude/skills/deploy-app/SKILL.md +45 -0
  5. package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
  6. package/claude/skills/deploy-app/references/vercel.md +72 -0
  7. package/docs/agents/architecture-record.md +12 -4
  8. package/docs/agents/canvas.md +13 -1
  9. package/docs/agents/commands.md +3 -2
  10. package/docs/agents/context-audit-checks.md +1 -1
  11. package/docs/agents/context-audit.md +4 -4
  12. package/docs/agents/index.md +2 -2
  13. package/docs/agents/teach.md +7 -1
  14. package/docs/workflow/skill-map.md +9 -9
  15. package/package.json +2 -1
  16. package/src/audits/catalog.ts +46 -1
  17. package/src/canvas/address.ts +73 -39
  18. package/src/canvas/client/inspector.tsx +241 -33
  19. package/src/canvas/client/pages-panel.tsx +51 -0
  20. package/src/canvas/client/shell.css +126 -1
  21. package/src/canvas/client/state.ts +120 -4
  22. package/src/canvas/client/theme-panel.tsx +55 -0
  23. package/src/canvas/content.ts +14 -3
  24. package/src/canvas/edit.ts +360 -0
  25. package/src/canvas/server.ts +50 -5
  26. package/src/canvas/tokens.ts +82 -0
  27. package/src/claude/cases/misc.ts +5 -1
  28. package/src/commands/canvas.ts +95 -1
  29. package/src/commands/context.ts +85 -5
  30. package/src/commands/slides.ts +73 -4
  31. package/src/commands/teach.ts +112 -4
  32. package/src/context/architecture.ts +98 -18
  33. package/src/context/gate.ts +30 -3
  34. package/src/context/requirements.ts +77 -0
  35. package/src/gate/measures.ts +69 -28
  36. package/src/gate/stages.ts +5 -5
  37. package/src/serve/live.ts +113 -0
  38. package/src/serve/static.ts +60 -2
  39. package/src/slides/convert/export.ts +323 -0
  40. package/src/slides/convert/shapes.ts +764 -0
  41. package/src/slides/convert/svg.ts +38 -0
  42. package/src/slides/convert/walk.ts +444 -0
  43. package/src/teach/browser/reload.ts +9 -0
  44. package/src/teach/nav.ts +7 -6
  45. package/src/teach/workspace.ts +2 -1
  46. package/src/teach/write-if-changed.ts +17 -0
  47. package/standards/architecture.md +13 -7
  48. package/standards/requirements.md +4 -0
  49. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  50. package/tooling/cloudflare/reference.md +1 -1
  51. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -0,0 +1,113 @@
1
+ import { type FSWatcher, watch } from 'node:fs'
2
+ import { SERVE_HOST } from '@/serve/static'
3
+
4
+ /**
5
+ * How long a burst of watch events for one path waits before it is sent. One
6
+ * editor save raises several, and each would otherwise reach a reader again.
7
+ */
8
+ const SETTLE_MS = 40
9
+
10
+ export interface LiveOptions<T> {
11
+ /**
12
+ * Turns a changed path, relative to the watched directory, into what a change
13
+ * carries, or into undefined to drop it, such as a write's staging file.
14
+ */
15
+ readonly map: (path: string) => T | undefined
16
+ /**
17
+ * Takes each settled change in place of the clients. A caller that has work
18
+ * to do before a reader may look, such as regenerating pages, sends its own
19
+ * payload through `send` once that work lands.
20
+ */
21
+ readonly onChange?: (payload: T) => void
22
+ }
23
+
24
+ /**
25
+ * Whether a request names this server by a loopback name and its own port.
26
+ * The bind keeps other machines out, and this keeps out a page the operator
27
+ * visits whose domain was rebound to 127.0.0.1, which reaches the socket with
28
+ * its own name in the Host header.
29
+ */
30
+ export function isLoopbackHost(host: string | null, port: number): boolean {
31
+ return host === `${SERVE_HOST}:${port}` || host === `localhost:${port}`
32
+ }
33
+
34
+ /**
35
+ * Watches a directory and fans each settled change out as a server-sent event
36
+ * to every open client, or to `onChange` when the caller takes changes itself.
37
+ */
38
+ export class LiveStream<T> {
39
+ private readonly clients = new Set<ReadableStreamDefaultController<string>>()
40
+ private readonly pending = new Map<string, ReturnType<typeof setTimeout>>()
41
+ private readonly watcher: FSWatcher
42
+
43
+ constructor(
44
+ dir: string,
45
+ private readonly options: LiveOptions<T>,
46
+ ) {
47
+ this.watcher = watch(dir, { recursive: true }, (_event, filename) => {
48
+ if (filename) this.queue(filename.toString())
49
+ })
50
+ }
51
+
52
+ private queue(path: string): void {
53
+ const payload = this.options.map(path)
54
+ if (payload === undefined) return
55
+ clearTimeout(this.pending.get(path))
56
+ this.pending.set(
57
+ path,
58
+ setTimeout(() => {
59
+ this.pending.delete(path)
60
+ if (this.options.onChange) this.options.onChange(payload)
61
+ else this.send(payload)
62
+ }, SETTLE_MS),
63
+ )
64
+ }
65
+
66
+ send(payload: unknown): void {
67
+ const frame = `data: ${JSON.stringify(payload)}\n\n`
68
+ for (const client of this.clients) client.enqueue(frame)
69
+ }
70
+
71
+ open(): Response {
72
+ let self: ReadableStreamDefaultController<string> | undefined
73
+ const stream = new ReadableStream<string>({
74
+ start: (controller) => {
75
+ self = controller
76
+ this.clients.add(controller)
77
+ controller.enqueue(': open\n\n')
78
+ },
79
+ cancel: () => {
80
+ if (self) this.clients.delete(self)
81
+ },
82
+ })
83
+ return new Response(stream, {
84
+ headers: {
85
+ 'content-type': 'text/event-stream',
86
+ 'cache-control': 'no-store',
87
+ },
88
+ })
89
+ }
90
+
91
+ /** Opens a stream for a request naming this server, and refuses any other. */
92
+ answer(request: Request, port: number): Response {
93
+ if (isLoopbackHost(request.headers.get('host'), port)) return this.open()
94
+ return new Response('Forbidden\n', {
95
+ status: 403,
96
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
97
+ })
98
+ }
99
+
100
+ close(): void {
101
+ this.watcher.close()
102
+ for (const timer of this.pending.values()) clearTimeout(timer)
103
+ this.pending.clear()
104
+ for (const client of this.clients) {
105
+ try {
106
+ client.close()
107
+ } catch {
108
+ /* Already closed by the browser going away. */
109
+ }
110
+ }
111
+ this.clients.clear()
112
+ }
113
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
2
2
  import { join, resolve, sep } from 'node:path'
3
+ import type { LiveStream } from '@/serve/live'
3
4
 
4
5
  /**
5
6
  * The loopback interface, never a wildcard bind. A preview serves whatever
@@ -78,11 +79,25 @@ export interface ServeStarted {
78
79
 
79
80
  export type ServeOutcome = ServeStarted | ServeRefused
80
81
 
82
+ /** The route a live page's script opens its event stream on. */
83
+ export const LIVE_EVENTS_PATH = '/__live'
84
+
85
+ export interface LiveServe {
86
+ readonly stream: Pick<LiveStream<unknown>, 'answer'>
87
+ /**
88
+ * The `<script>` element spliced into every page served, built by the caller
89
+ * so this module stays free of what a reload means to any one surface.
90
+ */
91
+ readonly script: string
92
+ }
93
+
81
94
  export interface ServeOptions {
82
95
  readonly port?: number
83
96
  readonly entry?: string
84
97
  /** Answers a directory request with no index page by listing it. Off by default. */
85
98
  readonly index?: boolean
99
+ /** Answers the events route and splices a script into each page. Off by default. */
100
+ readonly live?: LiveServe
86
101
  }
87
102
 
88
103
  function escapeHtml(text: string): string {
@@ -253,16 +268,59 @@ export function bindFirstFree(
253
268
  return undefined
254
269
  }
255
270
 
271
+ /**
272
+ * Splices the script in before the last closing body tag of a page. A page
273
+ * carrying none goes out as the bytes on disk, since guessing a place for it
274
+ * risks breaking markup the author wrote. The splice happens per response and
275
+ * never on disk, so a page the reader saves or commits carries no hook.
276
+ */
277
+ export async function injectLive(
278
+ response: Response,
279
+ script: string,
280
+ ): Promise<Response> {
281
+ const type = response.headers.get('content-type') ?? ''
282
+ if (response.status !== 200 || !type.startsWith('text/html')) return response
283
+
284
+ const bytes = new Uint8Array(await response.arrayBuffer())
285
+ const html = new TextDecoder().decode(bytes)
286
+ /*
287
+ * Searched in the original rather than a lowercased copy, since lowercasing
288
+ * changes the length of some characters, such as İ, and an index read off
289
+ * the copy would cut the original inside the tag.
290
+ */
291
+ const at = [...html.matchAll(/<\/body>/gi)].at(-1)?.index
292
+ const init = { status: response.status, headers: response.headers }
293
+ if (at === undefined) return new Response(bytes, init)
294
+ return new Response(`${html.slice(0, at)}${script}${html.slice(at)}`, init)
295
+ }
296
+
256
297
  function listen(
257
298
  root: string,
258
299
  first: number,
259
300
  index: boolean,
301
+ live: LiveServe | undefined,
260
302
  ): { server: BoundServer; port: number } | undefined {
303
+ if (!live) {
304
+ return bindFirstFree(first, (port) =>
305
+ Bun.serve({
306
+ hostname: SERVE_HOST,
307
+ port,
308
+ fetch: (request) => respond(root, request, index),
309
+ }),
310
+ )
311
+ }
261
312
  return bindFirstFree(first, (port) =>
262
313
  Bun.serve({
263
314
  hostname: SERVE_HOST,
264
315
  port,
265
- fetch: (request) => respond(root, request, index),
316
+ /* The event stream stays open for as long as the page does. */
317
+ idleTimeout: 0,
318
+ fetch: async (request, server) => {
319
+ if (new URL(request.url).pathname === LIVE_EVENTS_PATH) {
320
+ return live.stream.answer(request, server.port ?? 0)
321
+ }
322
+ return injectLive(await respond(root, request, index), live.script)
323
+ },
266
324
  }),
267
325
  )
268
326
  }
@@ -378,7 +436,7 @@ export function startServer(
378
436
  */
379
437
  let bound: ReturnType<typeof listen>
380
438
  try {
381
- bound = listen(root, first, options.index ?? false)
439
+ bound = listen(root, first, options.index ?? false, options.live)
382
440
  } catch (error) {
383
441
  const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
384
442
  return refuse(
@@ -0,0 +1,323 @@
1
+ import { copyFileSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+ import JSZip from 'jszip'
5
+ import type { Browser, Page } from 'playwright-core'
6
+ import PptxGenJS from 'pptxgenjs'
7
+ import { isBrowserMissing, isEngineMissing } from '@/browser/engine'
8
+ import { resolveFrameTokens } from '@/canvas/tokens'
9
+ import {
10
+ type DrawOp,
11
+ type Fallback,
12
+ planSlide,
13
+ type RefusedLink,
14
+ } from '@/slides/convert/shapes'
15
+ import {
16
+ ID_ATTRIBUTE,
17
+ type WalkedSlide,
18
+ walkSlide,
19
+ } from '@/slides/convert/walk'
20
+
21
+ /**
22
+ * Turns a folder of HTML slides into one editable deck. Chromium lays each
23
+ * slide out at 1280 by 720 in the project's tokens, `walkSlide` reads what it
24
+ * placed, `planSlide` maps the records onto shapes, and an element whose CSS has
25
+ * no mapping lands as a screenshot of itself.
26
+ *
27
+ * Every browser reference this feature adds lives here, and the command reaches
28
+ * it through a dynamic import so no other command resolves the engine.
29
+ */
30
+
31
+ export const SLIDE_WIDTH = 1280
32
+ export const SLIDE_HEIGHT = 720
33
+
34
+ /** Device pixels per CSS pixel in every screenshot, so a picture stays sharp. */
35
+ const SCREENSHOT_SCALE = 2
36
+
37
+ /** English Metric Units per inch, the unit a slide's XML measures in. */
38
+ const EMU_PER_INCH = 914400
39
+
40
+ export interface SlideFallback extends Fallback {
41
+ readonly slide: number
42
+ }
43
+
44
+ export interface SlideRefusedLink extends RefusedLink {
45
+ readonly slide: number
46
+ }
47
+
48
+ export type ExportRefusal =
49
+ | 'empty-source'
50
+ | 'engine-missing'
51
+ | 'browser-missing'
52
+ | 'export-failed'
53
+
54
+ export type ExportResult =
55
+ | {
56
+ readonly status: 'written'
57
+ readonly pptxPath: string
58
+ readonly mirrorPath?: string
59
+ readonly slideCount: number
60
+ readonly fallbacks: readonly SlideFallback[]
61
+ readonly refusedLinks: readonly SlideRefusedLink[]
62
+ }
63
+ | {
64
+ readonly status: 'refused'
65
+ readonly reason: ExportRefusal
66
+ readonly message: string
67
+ }
68
+
69
+ export interface ExportOptions {
70
+ /** The project whose tokens the slides are laid out in. */
71
+ readonly root: string
72
+ readonly mirror?: string
73
+ }
74
+
75
+ function refused(reason: ExportRefusal, message: string): ExportResult {
76
+ return { status: 'refused', reason, message }
77
+ }
78
+
79
+ /** Every `.html` file directly in the folder, in filename order. */
80
+ export function slideFiles(sourceDir: string): string[] {
81
+ return readdirSync(sourceDir, { withFileTypes: true })
82
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.html'))
83
+ .map((entry) => entry.name)
84
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
85
+ .map((name) => join(sourceDir, name))
86
+ }
87
+
88
+ /** A picture whose bytes the post-write pass replaces or whose corners it rounds. */
89
+ interface Patch {
90
+ readonly slide: number
91
+ readonly name: string
92
+ readonly png?: Buffer
93
+ readonly radius?: number
94
+ }
95
+
96
+ export async function exportHtmlDeck(
97
+ sourceDir: string,
98
+ outDir: string,
99
+ options: ExportOptions,
100
+ ): Promise<ExportResult> {
101
+ const files = slideFiles(sourceDir)
102
+ if (files.length === 0) {
103
+ return refused('empty-source', `${sourceDir} holds no .html slides`)
104
+ }
105
+
106
+ let chromium: typeof import('playwright-core').chromium
107
+ try {
108
+ ;({ chromium } = await import('playwright-core'))
109
+ } catch (error) {
110
+ if (!isEngineMissing(error)) throw error
111
+ return refused('engine-missing', 'playwright-core is not installed')
112
+ }
113
+
114
+ let browser: Browser
115
+ try {
116
+ browser = await chromium.launch()
117
+ } catch (error) {
118
+ const message = error instanceof Error ? error.message : String(error)
119
+ return refused(
120
+ isBrowserMissing(error) ? 'browser-missing' : 'export-failed',
121
+ message,
122
+ )
123
+ }
124
+
125
+ const tokens = resolveFrameTokens(options.root).css
126
+ const pptx = new PptxGenJS()
127
+ pptx.layout = 'LAYOUT_WIDE'
128
+ const fallbacks: SlideFallback[] = []
129
+ const refusedLinks: SlideRefusedLink[] = []
130
+ const patches: Patch[] = []
131
+
132
+ try {
133
+ const page = await browser.newPage({
134
+ viewport: { width: SLIDE_WIDTH, height: SLIDE_HEIGHT },
135
+ deviceScaleFactor: SCREENSHOT_SCALE,
136
+ })
137
+ for (const [index, file] of files.entries()) {
138
+ const slideNumber = index + 1
139
+ const walked = await layOut(page, file, tokens)
140
+ const plan = planSlide(walked.records, { slideCount: files.length })
141
+ fallbacks.push(
142
+ ...plan.fallbacks.map((each) => ({ ...each, slide: slideNumber })),
143
+ )
144
+ refusedLinks.push(
145
+ ...plan.refusedLinks.map((each) => ({ ...each, slide: slideNumber })),
146
+ )
147
+ const slide = pptx.addSlide()
148
+ slide.background = { color: walked.background.hex }
149
+ for (const [opIndex, op] of plan.ops.entries()) {
150
+ const name = `canon-${slideNumber}-${opIndex + 1}`
151
+ await draw(page, slide, { slide: slideNumber, name }, op, patches)
152
+ }
153
+ }
154
+ } catch (error) {
155
+ return refused(
156
+ 'export-failed',
157
+ error instanceof Error ? error.message : String(error),
158
+ )
159
+ } finally {
160
+ await browser.close().catch(() => undefined)
161
+ }
162
+
163
+ const written = await pptx.write({ outputType: 'nodebuffer' })
164
+ if (!(written instanceof Uint8Array)) {
165
+ return refused('export-failed', 'pptxgenjs returned no buffer')
166
+ }
167
+ const deck = await applyPatches(written, patches)
168
+
169
+ mkdirSync(outDir, { recursive: true })
170
+ const fileName = `${basename(sourceDir)}.pptx`
171
+ const pptxPath = join(outDir, fileName)
172
+ writeFileSync(pptxPath, deck)
173
+
174
+ let mirrorPath: string | undefined
175
+ if (options.mirror) {
176
+ mkdirSync(options.mirror, { recursive: true })
177
+ mirrorPath = join(options.mirror, fileName)
178
+ copyFileSync(pptxPath, mirrorPath)
179
+ }
180
+
181
+ return {
182
+ status: 'written',
183
+ pptxPath,
184
+ mirrorPath,
185
+ slideCount: files.length,
186
+ fallbacks,
187
+ refusedLinks,
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The tokens go in ahead of the slide's own styles, so a slide reads
193
+ * `var(--color-*)` from the project and can still override any of it.
194
+ */
195
+ async function layOut(
196
+ page: Page,
197
+ file: string,
198
+ tokens: string,
199
+ ): Promise<WalkedSlide> {
200
+ await page.goto(pathToFileURL(file).href, { waitUntil: 'load' })
201
+ if (tokens) {
202
+ await page.evaluate((css) => {
203
+ const style = document.createElement('style')
204
+ style.textContent = css
205
+ document.head.prepend(style)
206
+ }, tokens)
207
+ }
208
+ await page.evaluate(() => document.fonts.ready.then(() => undefined))
209
+ return page.evaluate(walkSlide, ID_ATTRIBUTE)
210
+ }
211
+
212
+ async function screenshot(page: Page, id: number): Promise<Buffer> {
213
+ return page
214
+ .locator(`[${ID_ATTRIBUTE}="${id}"]`)
215
+ .screenshot({ omitBackground: true, animations: 'disabled' })
216
+ }
217
+
218
+ function imageSource(src: string): Pick<PptxGenJS.ImageProps, 'path' | 'data'> {
219
+ if (src.startsWith('data:')) return { data: src.slice('data:'.length) }
220
+ if (src.startsWith('file:')) return { path: fileURLToPath(src) }
221
+ return { path: src }
222
+ }
223
+
224
+ const base64 = (bytes: Buffer | string): string =>
225
+ Buffer.from(bytes).toString('base64')
226
+
227
+ async function draw(
228
+ page: Page,
229
+ slide: PptxGenJS.Slide,
230
+ target: { readonly slide: number; readonly name: string },
231
+ op: DrawOp,
232
+ patches: Patch[],
233
+ ): Promise<void> {
234
+ if (op.kind === 'shape') {
235
+ slide.addShape(op.shape, op.options)
236
+ } else if (op.kind === 'text') {
237
+ slide.addText(op.runs, op.options)
238
+ } else if (op.kind === 'table') {
239
+ slide.addTable(op.rows, op.options)
240
+ } else if (op.kind === 'image') {
241
+ slide.addImage({
242
+ ...op.options,
243
+ ...imageSource(op.src),
244
+ objectName: target.name,
245
+ })
246
+ if (op.radius > 0) patches.push({ ...target, radius: op.radius })
247
+ if (op.frame) slide.addShape(op.radius > 0 ? 'roundRect' : 'rect', op.frame)
248
+ } else if (op.kind === 'svg') {
249
+ slide.addImage({
250
+ ...op.options,
251
+ data: `image/svg+xml;base64,${base64(op.markup)}`,
252
+ objectName: target.name,
253
+ })
254
+ patches.push({ ...target, png: await screenshot(page, op.id) })
255
+ } else {
256
+ const png = await screenshot(page, op.id)
257
+ slide.addImage({ ...op.options, data: `image/png;base64,${base64(png)}` })
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Two things pptxgenjs cannot write, fixed in the package it wrote. An SVG's
263
+ * PNG fallback comes out as the SVG's own bytes under a `.png` name in Node, so
264
+ * a viewer that cannot draw SVG gets a broken image, and a picture takes no
265
+ * corner radius. Each patched picture is found by the object name `draw` gave
266
+ * it, which is the only handle the written XML keeps.
267
+ */
268
+ async function applyPatches(
269
+ deck: Uint8Array,
270
+ patches: readonly Patch[],
271
+ ): Promise<Buffer> {
272
+ const zip = await JSZip.loadAsync(deck)
273
+ const bySlide = Map.groupBy(patches, (patch) => patch.slide)
274
+ for (const [slideNumber, slidePatches] of bySlide) {
275
+ const slidePath = `ppt/slides/slide${slideNumber}.xml`
276
+ const relsPath = `ppt/slides/_rels/slide${slideNumber}.xml.rels`
277
+ let xml = (await zip.file(slidePath)?.async('string')) ?? ''
278
+ const rels = (await zip.file(relsPath)?.async('string')) ?? ''
279
+ for (const patch of slidePatches) {
280
+ const pic = pictureXml(xml, patch.name)
281
+ if (!pic) continue
282
+ if (patch.png) {
283
+ const rId = /<a:blip r:embed="(rId\d+)"/.exec(pic)?.[1]
284
+ const target = rId
285
+ ? new RegExp(`Id="${rId}"[^>]*Target="\\.\\./([^"]+)"`).exec(
286
+ rels,
287
+ )?.[1]
288
+ : undefined
289
+ if (target) zip.file(`ppt/${target}`, patch.png)
290
+ }
291
+ if (patch.radius) {
292
+ xml = xml.replace(pic, roundCorners(pic, patch.radius))
293
+ }
294
+ }
295
+ zip.file(slidePath, xml)
296
+ }
297
+ return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
298
+ }
299
+
300
+ function pictureXml(xml: string, name: string): string | undefined {
301
+ const start = xml.indexOf(`name="${name}"`)
302
+ if (start < 0) return undefined
303
+ const open = xml.lastIndexOf('<p:pic>', start)
304
+ const close = xml.indexOf('</p:pic>', start)
305
+ return open < 0 || close < 0
306
+ ? undefined
307
+ : xml.slice(open, close + '</p:pic>'.length)
308
+ }
309
+
310
+ /** The adjust value is the radius over the shorter side, out of 100000. */
311
+ function roundCorners(pic: string, radius: number): string {
312
+ const extent = /<a:ext cx="(\d+)" cy="(\d+)"\/>/.exec(pic)
313
+ const shorter = extent ? Math.min(Number(extent[1]), Number(extent[2])) : 0
314
+ if (shorter === 0) return pic
315
+ const adjust = Math.min(
316
+ 50000,
317
+ Math.round((radius * EMU_PER_INCH * 100000) / shorter),
318
+ )
319
+ return pic.replace(
320
+ /<a:prstGeom prst="rect"><a:avLst\/><\/a:prstGeom>/,
321
+ `<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val ${adjust}"/></a:avLst></a:prstGeom>`,
322
+ )
323
+ }