caracms 0.0.0-stage → 0.2.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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,599 @@
1
+ /**
2
+ * What an uploaded file is, **from its bytes, never its name** (M5b Decision 6), and its
3
+ * pixel size as shown (after EXIF orientation; an SVG's from its `viewBox`; a HEIC's or
4
+ * an AVIF's from the primary item's `ispe`, turned by its `irot`).
5
+ *
6
+ * The library takes JPEG, PNG, WebP, AVIF, HEIC/HEIF, GIF and SVG, and (M6c G4, Decision
7
+ * 1) the files in `caracms/files`' table: PDF, Word, Excel, PowerPoint, CSV and Zip.
8
+ * Anything else is refused with Decision 6's sentence: "This is a video. …" when the
9
+ * bytes say what it is, "This file isn't one the library can take: …" when they don't;
10
+ * both list images and files.
11
+ *
12
+ * **Files** (`sniffFile`): a PDF by `%PDF-`; a zip by its header, read through its
13
+ * central directory, and an Office file when it holds `[Content_Types].xml` and a
14
+ * `word/`, `xl/` or `ppt/` part (a macro-enabled one, by its content types or a
15
+ * `vbaProject.bin`, is refused). **The name decides only two things, and only once the
16
+ * bytes allow it**: which legacy Office type an OLE file is (`.doc`, `.xls`, `.ppt`),
17
+ * and that valid UTF-8 text with no NUL byte is a CSV (`.csv`). Nowhere else.
18
+ *
19
+ * Pure and dependency-free: plain byte reads with bounds checks, so a truncated or
20
+ * hostile file reads as "not an image", never a throw. **An SVG is never parsed as
21
+ * XML**: its prolog is skipped by hand and its root element's attributes read with a
22
+ * regular expression, so nothing here could resolve an entity or fetch anything. It's
23
+ * only ever shown through imgproxy in an `<img>`, where scripts don't run.
24
+ */
25
+ import { inflateRawSync } from 'node:zlib';
26
+ import { fileTypeOf, type FileType } from '../../../files.ts';
27
+ import { FILE_TYPES_TEXT, MEDIA_TYPES_TEXT } from '../../types.ts';
28
+
29
+ export type ImageFormat = 'jpeg' | 'png' | 'webp' | 'avif' | 'heic' | 'heif' | 'gif' | 'svg';
30
+
31
+ export interface SniffedImage {
32
+ ok: true;
33
+ format: ImageFormat;
34
+ /** The key's extension: from the type, never the file's name. */
35
+ ext: string;
36
+ /** The object's Content-Type. */
37
+ mime: string;
38
+ /** As displayed: after EXIF orientation 5–8 (or `irot` 90°/270°) swaps them. */
39
+ w: number;
40
+ h: number;
41
+ }
42
+
43
+ /** M6c G4: a file the library takes (Decision 1), as its row in the file-type table. */
44
+ export interface SniffedFile {
45
+ ok: true;
46
+ format: 'file';
47
+ /** The key's extension: from the bytes (and, for a legacy Office file or a CSV, the name once the bytes allow it). */
48
+ ext: string;
49
+ mime: string;
50
+ type: FileType;
51
+ }
52
+
53
+ export interface SniffRefusal {
54
+ ok: false;
55
+ code: 'not-an-image' | 'unknown-type' | 'no-size' | 'macro-enabled' | 'not-a-csv' | 'unnamed-office';
56
+ message: string;
57
+ }
58
+
59
+ const FORMATS: Record<ImageFormat, { ext: string; mime: string }> = {
60
+ jpeg: { ext: 'jpg', mime: 'image/jpeg' },
61
+ png: { ext: 'png', mime: 'image/png' },
62
+ webp: { ext: 'webp', mime: 'image/webp' },
63
+ avif: { ext: 'avif', mime: 'image/avif' },
64
+ heic: { ext: 'heic', mime: 'image/heic' },
65
+ heif: { ext: 'heif', mime: 'image/heif' },
66
+ gif: { ext: 'gif', mime: 'image/gif' },
67
+ svg: { ext: 'svg', mime: 'image/svg+xml' },
68
+ };
69
+
70
+ /** What every refusal says the library takes (M6c G4: images and files). */
71
+ const TAKES = `images (${MEDIA_TYPES_TEXT}) and files (${FILE_TYPES_TEXT})`;
72
+ /** Decision 6's sentence for a file the bytes can name ("a video"). */
73
+ export const namedTypeMessage = (what: string) => `This is ${what}. The library takes ${TAKES}.`;
74
+ /** Decision 6's sentence for a file the bytes can't name. */
75
+ export const UNKNOWN_TYPE_MESSAGE = `This file isn't one the library can take. It takes ${TAKES}.`;
76
+ export const SVG_NO_SIZE_MESSAGE = "This SVG has no size (no viewBox, and no width and height), so the library can't place it. Add a viewBox and upload it again.";
77
+
78
+ const refuse = (code: SniffRefusal['code'], message: string): SniffRefusal => ({ ok: false, code, message });
79
+ const unknown = (): SniffRefusal => refuse('unknown-type', UNKNOWN_TYPE_MESSAGE);
80
+ const named = (what: string): SniffRefusal => refuse('not-an-image', namedTypeMessage(what));
81
+
82
+ function image(format: ImageFormat, w: number, h: number, swap = false): SniffedImage | SniffRefusal {
83
+ if (!(w > 0 && h > 0 && Number.isFinite(w) && Number.isFinite(h))) return unknown();
84
+ return { ok: true, format, ...FORMATS[format], w: swap ? h : w, h: swap ? w : h };
85
+ }
86
+
87
+ /** Bounds-checked reads: out of range is `undefined`/NaN, which every caller treats as "not this". */
88
+ class Bytes {
89
+ readonly b: Uint8Array;
90
+ constructor(b: Uint8Array) {
91
+ this.b = b;
92
+ }
93
+ get length() {
94
+ return this.b.length;
95
+ }
96
+ u8(i: number): number {
97
+ return i >= 0 && i < this.b.length ? this.b[i]! : NaN;
98
+ }
99
+ u16be(i: number) {
100
+ return this.u8(i) * 256 + this.u8(i + 1);
101
+ }
102
+ u16le(i: number) {
103
+ return this.u8(i) + this.u8(i + 1) * 256;
104
+ }
105
+ u24le(i: number) {
106
+ return this.u8(i) + this.u8(i + 1) * 256 + this.u8(i + 2) * 65536;
107
+ }
108
+ u32be(i: number) {
109
+ return this.u16be(i) * 65536 + this.u16be(i + 2);
110
+ }
111
+ u32le(i: number) {
112
+ return this.u16le(i) + this.u16le(i + 2) * 65536;
113
+ }
114
+ ascii(i: number, n: number): string {
115
+ if (i < 0 || i + n > this.b.length) return '';
116
+ let s = '';
117
+ for (let k = 0; k < n; k++) s += String.fromCharCode(this.b[i + k]!);
118
+ return s;
119
+ }
120
+ starts(...bytes: number[]): boolean {
121
+ return bytes.every((v, k) => this.b[k] === v);
122
+ }
123
+ }
124
+
125
+ /** Sniff a whole file. Never throws. */
126
+ export function sniffImage(data: Uint8Array): SniffedImage | SniffRefusal {
127
+ const b = new Bytes(data);
128
+ if (b.length === 0) return unknown();
129
+ try {
130
+ if (b.starts(0xff, 0xd8, 0xff)) return jpeg(b);
131
+ if (b.starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return png(b);
132
+ const head6 = b.ascii(0, 6);
133
+ if (head6 === 'GIF87a' || head6 === 'GIF89a') return image('gif', b.u16le(6), b.u16le(8));
134
+ if (b.ascii(0, 4) === 'RIFF') {
135
+ const kind = b.ascii(8, 4);
136
+ if (kind === 'WEBP') return webp(b);
137
+ if (kind === 'AVI ') return named('a video');
138
+ if (kind === 'WAVE') return named('an audio file');
139
+ return unknown();
140
+ }
141
+ if (b.ascii(4, 4) === 'ftyp') return isoMedia(b);
142
+ const other = otherType(b);
143
+ if (other) return named(other);
144
+ return text(data);
145
+ } catch {
146
+ return unknown();
147
+ }
148
+ }
149
+
150
+ // ── JPEG ──────────────────────────────────────────────────────────────────────
151
+
152
+ function jpeg(b: Bytes): SniffedImage | SniffRefusal {
153
+ let orientation = 1;
154
+ let i = 2;
155
+ while (i + 3 < b.length) {
156
+ if (b.u8(i) !== 0xff) return unknown();
157
+ const marker = b.u8(i + 1);
158
+ if (marker === 0xff) {
159
+ i++; // fill byte
160
+ continue;
161
+ }
162
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
163
+ i += 2; // markers with no length
164
+ continue;
165
+ }
166
+ if (marker === 0xd9 || marker === 0xda) break; // end of image, or the scan, before any frame header
167
+ const len = b.u16be(i + 2);
168
+ if (!(len >= 2)) break;
169
+ if (marker === 0xe1 && b.ascii(i + 4, 6) === 'Exif\0\0') orientation = exifOrientation(b, i + 10, i + 2 + len) ?? orientation;
170
+ // SOF0–SOF15, except DHT (C4), JPG (C8) and DAC (CC): height then width.
171
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
172
+ return image('jpeg', b.u16be(i + 7), b.u16be(i + 5), swaps(orientation));
173
+ }
174
+ i += 2 + len;
175
+ }
176
+ return unknown();
177
+ }
178
+
179
+ /** EXIF orientations 5–8 turn the picture a quarter, so the shown width is the stored height. */
180
+ const swaps = (orientation: number) => orientation >= 5 && orientation <= 8;
181
+
182
+ /** The orientation tag (0x0112) in IFD0 of a TIFF structure at `tiff`, within `end`; null when there's none. */
183
+ function exifOrientation(b: Bytes, tiff: number, end: number): number | null {
184
+ const order = b.ascii(tiff, 2);
185
+ if (order !== 'II' && order !== 'MM') return null;
186
+ const le = order === 'II';
187
+ const u16 = (o: number) => (o + 2 <= end ? (le ? b.u16le(o) : b.u16be(o)) : NaN);
188
+ const u32 = (o: number) => (o + 4 <= end ? (le ? b.u32le(o) : b.u32be(o)) : NaN);
189
+ if (u16(tiff + 2) !== 42) return null;
190
+ const ifd = tiff + u32(tiff + 4);
191
+ const count = u16(ifd);
192
+ if (!(count >= 0) || count > 1000) return null;
193
+ for (let n = 0, e = ifd + 2; n < count; n++, e += 12) {
194
+ if (u16(e) === 0x0112) {
195
+ const value = u16(e + 8);
196
+ return value >= 1 && value <= 8 ? value : null;
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ // ── PNG ───────────────────────────────────────────────────────────────────────
203
+
204
+ function png(b: Bytes): SniffedImage | SniffRefusal {
205
+ if (b.ascii(12, 4) !== 'IHDR') return unknown();
206
+ const w = b.u32be(16);
207
+ const h = b.u32be(20);
208
+ // An eXIf chunk's orientation (imgproxy rotates by it, as for a JPEG). It must come before IDAT.
209
+ let orientation = 1;
210
+ for (let i = 8; i + 8 <= b.length; ) {
211
+ const len = b.u32be(i);
212
+ const type = b.ascii(i + 4, 4);
213
+ if (!(len >= 0) || type === 'IDAT' || type === 'IEND' || type === '') break;
214
+ if (type === 'eXIf') orientation = exifOrientation(b, i + 8, i + 8 + len) ?? orientation;
215
+ i += 12 + len;
216
+ }
217
+ return image('png', w, h, swaps(orientation));
218
+ }
219
+
220
+ // ── WebP ──────────────────────────────────────────────────────────────────────
221
+
222
+ function webp(b: Bytes): SniffedImage | SniffRefusal {
223
+ const chunk = b.ascii(12, 4);
224
+ let w = NaN;
225
+ let h = NaN;
226
+ if (chunk === 'VP8X') {
227
+ w = b.u24le(24) + 1;
228
+ h = b.u24le(27) + 1;
229
+ } else if (chunk === 'VP8L') {
230
+ if (b.u8(20) !== 0x2f) return unknown();
231
+ const bits = b.u32le(21);
232
+ w = (bits & 0x3fff) + 1;
233
+ h = ((bits >>> 14) & 0x3fff) + 1;
234
+ } else if (chunk === 'VP8 ') {
235
+ if (!(b.u8(23) === 0x9d && b.u8(24) === 0x01 && b.u8(25) === 0x2a)) return unknown();
236
+ w = b.u16le(26) & 0x3fff;
237
+ h = b.u16le(28) & 0x3fff;
238
+ } else return unknown();
239
+ // An extended file's EXIF chunk (orientation), wherever it sits.
240
+ let orientation = 1;
241
+ if (chunk === 'VP8X') {
242
+ for (let i = 12; i + 8 <= b.length; ) {
243
+ const type = b.ascii(i, 4);
244
+ const len = b.u32le(i + 4);
245
+ if (!(len >= 0) || type === '') break;
246
+ if (type === 'EXIF') {
247
+ const start = b.ascii(i + 8, 6) === 'Exif\0\0' ? i + 14 : i + 8;
248
+ orientation = exifOrientation(b, start, i + 8 + len) ?? orientation;
249
+ break;
250
+ }
251
+ i += 8 + len + (len % 2);
252
+ }
253
+ }
254
+ return image('webp', w, h, swaps(orientation));
255
+ }
256
+
257
+ // ── AVIF and HEIC/HEIF (ISO base media) ───────────────────────────────────────
258
+
259
+ const AVIF_BRANDS = new Set(['avif', 'avis']);
260
+ const HEIC_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'hevm', 'hevs']);
261
+ const HEIF_BRANDS = new Set(['mif1', 'msf1']);
262
+ const VIDEO_BRANDS = new Set(['isom', 'iso2', 'iso4', 'iso5', 'iso6', 'mp41', 'mp42', 'qt ', 'M4V ', 'M4VP', '3gp4', '3gp5', '3gp6', '3g2a', 'avc1', 'dash', 'MSNV', 'f4v ', 'crx ']);
263
+ const AUDIO_BRANDS = new Set(['M4A ', 'M4B ', 'M4P ', 'F4A ']);
264
+
265
+ interface Box {
266
+ type: string;
267
+ /** The box's content (after its header). */
268
+ start: number;
269
+ end: number;
270
+ }
271
+
272
+ /** The boxes in [from, to). */
273
+ function boxes(b: Bytes, from: number, to: number): Box[] {
274
+ const out: Box[] = [];
275
+ for (let i = from; i + 8 <= to && out.length < 4096; ) {
276
+ let size = b.u32be(i);
277
+ const type = b.ascii(i + 4, 4);
278
+ let header = 8;
279
+ if (size === 1) {
280
+ const high = b.u32be(i + 8);
281
+ const low = b.u32be(i + 12);
282
+ if (high !== 0) break; // over 4 GB: not in a 40 MB file
283
+ size = low;
284
+ header = 16;
285
+ } else if (size === 0) size = to - i;
286
+ if (!(size >= header) || i + size > to || type === '') break;
287
+ out.push({ type, start: i + header, end: i + size });
288
+ i += size;
289
+ }
290
+ return out;
291
+ }
292
+
293
+ function isoMedia(b: Bytes): SniffedImage | SniffRefusal {
294
+ const top = boxes(b, 0, b.length);
295
+ const ftyp = top[0];
296
+ if (!ftyp || ftyp.type !== 'ftyp') return unknown();
297
+ const brands = new Set<string>([b.ascii(ftyp.start, 4)]);
298
+ for (let i = ftyp.start + 8; i + 4 <= ftyp.end; i += 4) brands.add(b.ascii(i, 4));
299
+ const has = (set: Set<string>) => [...brands].some((x) => set.has(x));
300
+ const format: ImageFormat | null = has(AVIF_BRANDS) ? 'avif' : has(HEIC_BRANDS) ? 'heic' : has(HEIF_BRANDS) ? 'heif' : null;
301
+ if (!format) {
302
+ if (has(VIDEO_BRANDS)) return named('a video');
303
+ if (has(AUDIO_BRANDS)) return named('an audio file');
304
+ return unknown();
305
+ }
306
+ const meta = top.find((x) => x.type === 'meta');
307
+ if (!meta) return unknown();
308
+ const size = primarySize(b, meta.start + 4, meta.end); // meta is a full box: skip version and flags
309
+ return size ? image(format, size.w, size.h, size.quarter) : unknown();
310
+ }
311
+
312
+ /** The primary item's `ispe` and `irot` (HEIF's own rotation; EXIF's isn't used for HEIF). */
313
+ function primarySize(b: Bytes, from: number, to: number): { w: number; h: number; quarter: boolean } | null {
314
+ const children = boxes(b, from, to);
315
+ const pitm = children.find((x) => x.type === 'pitm');
316
+ const primary = pitm ? (b.u8(pitm.start) === 0 ? b.u16be(pitm.start + 4) : b.u32be(pitm.start + 4)) : NaN;
317
+ const iprp = children.find((x) => x.type === 'iprp');
318
+ if (!iprp) return null;
319
+ const inner = boxes(b, iprp.start, iprp.end);
320
+ const ipco = inner.find((x) => x.type === 'ipco');
321
+ if (!ipco) return null;
322
+ const props = boxes(b, ipco.start, ipco.end); // 1-based in ipma
323
+ // The primary item's property indexes, from every ipma box.
324
+ const mine = new Set<number>();
325
+ for (const ipma of inner.filter((x) => x.type === 'ipma')) {
326
+ const version = b.u8(ipma.start);
327
+ const wide = (b.u8(ipma.start + 3) & 1) === 1;
328
+ let i = ipma.start + 4;
329
+ const count = b.u32be(i);
330
+ i += 4;
331
+ for (let n = 0; n < count && i < ipma.end; n++) {
332
+ const item = version < 1 ? b.u16be(i) : b.u32be(i);
333
+ i += version < 1 ? 2 : 4;
334
+ const associations = b.u8(i);
335
+ i += 1;
336
+ for (let k = 0; k < associations; k++) {
337
+ const index = wide ? b.u16be(i) & 0x7fff : b.u8(i) & 0x7f;
338
+ i += wide ? 2 : 1;
339
+ if (item === primary) mine.add(index);
340
+ }
341
+ }
342
+ }
343
+ const ispeOf = (p: Box) => ({ w: b.u32be(p.start + 4), h: b.u32be(p.start + 8) });
344
+ let size: { w: number; h: number } | null = null;
345
+ let quarter = false;
346
+ props.forEach((p, k) => {
347
+ if (!mine.has(k + 1)) return;
348
+ if (p.type === 'ispe') size = ispeOf(p);
349
+ if (p.type === 'irot') quarter = (b.u8(p.start) & 3) % 2 === 1;
350
+ });
351
+ if (!size) {
352
+ // No association read: the largest ispe is the full picture (the others are tiles and thumbnails).
353
+ for (const p of props) {
354
+ if (p.type !== 'ispe') continue;
355
+ const s = ispeOf(p);
356
+ if (!size || s.w * s.h > size.w * size.h) size = s;
357
+ }
358
+ if (!size) return null;
359
+ quarter = props.some((p) => p.type === 'irot' && (b.u8(p.start) & 3) % 2 === 1);
360
+ }
361
+ const found = size as { w: number; h: number };
362
+ return { w: found.w, h: found.h, quarter };
363
+ }
364
+
365
+ // ── Other files the bytes can name ────────────────────────────────────────────
366
+
367
+ function otherType(b: Bytes): string | null {
368
+ if (b.ascii(0, 5) === '%PDF-') return 'a PDF';
369
+ if (b.starts(0x50, 0x4b, 0x03, 0x04) || b.starts(0x50, 0x4b, 0x05, 0x06)) return 'a ZIP file';
370
+ if (b.ascii(0, 2) === 'MZ' || b.starts(0x7f, 0x45, 0x4c, 0x46) || isMachO(b) || b.ascii(0, 2) === '#!') return 'a program';
371
+ if (b.starts(0x1a, 0x45, 0xdf, 0xa3)) return 'a video'; // WebM, MKV
372
+ if (b.ascii(0, 4) === 'OggS' || b.ascii(0, 4) === 'fLaC' || b.ascii(0, 3) === 'ID3') return 'an audio file';
373
+ if (b.starts(0x49, 0x49, 0x2a, 0x00) || b.starts(0x4d, 0x4d, 0x00, 0x2a)) return 'a TIFF or camera raw image';
374
+ if (b.ascii(0, 4) === '8BPS') return 'a Photoshop file';
375
+ if (b.starts(0xff, 0x0a) || (b.u32be(0) === 12 && b.ascii(4, 4) === 'JXL ')) return 'a JPEG XL image';
376
+ if (b.ascii(0, 2) === 'BM' && b.u32le(2) > 26 && b.u16le(26) === 1) return 'a BMP image';
377
+ if (b.starts(0x00, 0x00, 0x01, 0x00) && b.u16le(4) > 0) return 'an icon file';
378
+ if (b.ascii(0, 4) === 'Rar!' || b.starts(0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c) || b.starts(0x1f, 0x8b)) return 'a compressed file';
379
+ if (b.starts(0xd0, 0xcf, 0x11, 0xe0)) return 'an Office document';
380
+ if (b.ascii(0, 5) === '{\\rtf') return 'a document';
381
+ if (b.u8(0) === 0xff && (b.u8(1) & 0xe0) === 0xe0) return 'an audio file'; // an MP3 frame
382
+ return null;
383
+ }
384
+
385
+ /** A Mach-O binary (32- or 64-bit, either byte order, or a universal one with a small arch count: CAFEBABE is also a Java class). */
386
+ function isMachO(b: Bytes): boolean {
387
+ const m = b.u32be(0);
388
+ if (m === 0xfeedface || m === 0xfeedfacf || m === 0xcefaedfe || m === 0xcffaedfe) return true;
389
+ return m === 0xcafebabe && b.u32be(4) > 0 && b.u32be(4) < 20;
390
+ }
391
+
392
+ // ── Files (M6c G4, Decision 1) ────────────────────────────────────────────────
393
+
394
+ const typeOf = (ext: string): FileType => fileTypeOf(`x.${ext}`)!;
395
+ const file = (ext: string): SniffedFile => {
396
+ const type = typeOf(ext);
397
+ return { ok: true, format: 'file', ext: type.ext, mime: type.contentType, type };
398
+ };
399
+
400
+ /** The ordinary format to save as, for a macro-enabled one's sentence. */
401
+ const ORDINARY: Record<string, string> = {
402
+ docx: 'This Word document has macros, which the library doesn’t take. Save it as an ordinary Word document (.docx) and upload that.',
403
+ xlsx: 'This Excel file has macros, which the library doesn’t take. Save it as an ordinary Excel workbook (.xlsx) and upload that.',
404
+ pptx: 'This PowerPoint file has macros, which the library doesn’t take. Save it as an ordinary PowerPoint presentation (.pptx) and upload that.',
405
+ };
406
+
407
+ export const NOT_A_CSV_MESSAGE = 'Not plain-text CSV. Export as CSV (UTF-8).';
408
+ export const UNNAMED_OFFICE_MESSAGE = 'This is an older Office file, and its name doesn’t say which kind (.doc, .xls or .ppt). Save it in the current format (.docx, .xlsx or .pptx) and upload that.';
409
+
410
+ const OLE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
411
+ const LEGACY = new Set(['doc', 'xls', 'ppt']);
412
+ /** How much of `[Content_Types].xml` is read: far more than any real one. */
413
+ const CONTENT_TYPES_CAP = 1_000_000;
414
+
415
+ /**
416
+ * A file the library takes, a refusal that's a file's own (macros, a CSV that isn't text,
417
+ * an unnamed legacy Office file), or null when the bytes aren't a file of the table's
418
+ * (the image sniffer's sentence stands). Never throws.
419
+ */
420
+ export function sniffFile(data: Uint8Array, name: string): SniffedFile | SniffRefusal | null {
421
+ const b = new Bytes(data);
422
+ if (b.length === 0) return null;
423
+ try {
424
+ if (b.ascii(0, 5) === '%PDF-') return file('pdf');
425
+ if (b.starts(0x50, 0x4b, 0x03, 0x04) || b.starts(0x50, 0x4b, 0x05, 0x06)) return zipOrOffice(b);
426
+ const ext = (/\.([a-z0-9]+)$/i.exec(name)?.[1] ?? '').toLowerCase();
427
+ if (b.starts(...OLE)) {
428
+ // The name's narrow exception (1): an OLE file is a legacy Office one only by its name's extension.
429
+ return LEGACY.has(ext) ? file(ext) : refuse('unnamed-office', UNNAMED_OFFICE_MESSAGE);
430
+ }
431
+ // The name's narrow exception (2): text is a CSV only when it's called one.
432
+ if (ext === 'csv') return isText(data) ? file('csv') : refuse('not-a-csv', NOT_A_CSV_MESSAGE);
433
+ return null;
434
+ } catch {
435
+ return null;
436
+ }
437
+ }
438
+
439
+ /** Valid UTF-8 (a BOM allowed) with no NUL byte. */
440
+ function isText(data: Uint8Array): boolean {
441
+ if (data.includes(0)) return false;
442
+ try {
443
+ new TextDecoder('utf-8', { fatal: true }).decode(data);
444
+ return true;
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+
450
+ interface ZipEntry {
451
+ name: string;
452
+ method: number;
453
+ compressed: number;
454
+ size: number;
455
+ local: number;
456
+ }
457
+
458
+ /** The zip's entries, from its central directory; null when it can't be read (a damaged or truncated file). */
459
+ function zipEntries(b: Bytes): ZipEntry[] | null {
460
+ // The end-of-central-directory record: the last 22 bytes, or up to 64 KB more of comment before them.
461
+ let eocd = -1;
462
+ for (let i = b.length - 22; i >= 0 && i >= b.length - 22 - 0xffff; i--) {
463
+ if (b.u32le(i) === 0x06054b50) {
464
+ eocd = i;
465
+ break;
466
+ }
467
+ }
468
+ if (eocd < 0) return null;
469
+ const count = b.u16le(eocd + 10);
470
+ const offset = b.u32le(eocd + 16);
471
+ if (!(offset >= 0) || offset > eocd) return null;
472
+ const out: ZipEntry[] = [];
473
+ for (let i = offset, n = 0; n < count && n < 65535; n++) {
474
+ if (b.u32le(i) !== 0x02014b50) return null;
475
+ const nameLength = b.u16le(i + 28);
476
+ const extra = b.u16le(i + 30);
477
+ const comment = b.u16le(i + 32);
478
+ const name = b.ascii(i + 46, nameLength);
479
+ if (!(nameLength >= 0) || (nameLength > 0 && name === '')) return null;
480
+ out.push({ name, method: b.u16le(i + 10), compressed: b.u32le(i + 20), size: b.u32le(i + 24), local: b.u32le(i + 42) });
481
+ i += 46 + nameLength + extra + comment;
482
+ }
483
+ return out;
484
+ }
485
+
486
+ /** One entry's bytes (stored or deflated), up to `cap`; null when they can't be read. */
487
+ function zipRead(b: Bytes, entry: ZipEntry, cap: number): Uint8Array | null {
488
+ const at = entry.local;
489
+ if (b.u32le(at) !== 0x04034b50) return null;
490
+ const start = at + 30 + b.u16le(at + 26) + b.u16le(at + 28);
491
+ const end = start + entry.compressed;
492
+ if (!(start >= 0) || !(end <= b.length)) return null;
493
+ const raw = b.b.subarray(start, end);
494
+ if (entry.method === 0) return raw.subarray(0, cap);
495
+ if (entry.method !== 8) return null;
496
+ try {
497
+ return new Uint8Array(inflateRawSync(raw, { maxOutputLength: cap }));
498
+ } catch {
499
+ return null;
500
+ }
501
+ }
502
+
503
+ function zipOrOffice(b: Bytes): SniffedFile | SniffRefusal {
504
+ const entries = zipEntries(b);
505
+ if (!entries) return unknown();
506
+ const names = entries.map((e) => e.name);
507
+ const types = entries.find((e) => e.name === '[Content_Types].xml');
508
+ const office = !types ? null : names.some((n) => n.startsWith('word/')) ? 'docx' : names.some((n) => n.startsWith('xl/')) ? 'xlsx' : names.some((n) => n.startsWith('ppt/')) ? 'pptx' : null;
509
+ if (!office) return file('zip');
510
+ const xml = zipRead(b, types!, CONTENT_TYPES_CAP);
511
+ const macros = (xml && /macroEnabled/i.test(new TextDecoder('utf-8', { fatal: false }).decode(xml))) || names.some((n) => /^(word|xl|ppt)\/vbaProject\.bin$/i.test(n));
512
+ if (macros) return refuse('macro-enabled', ORDINARY[office]!);
513
+ return file(office);
514
+ }
515
+
516
+ /**
517
+ * What an upload is (M6c G4): an image, else a file of the table's, else the image
518
+ * sniffer's refusal (which names what the bytes are when they say). `name` is the
519
+ * uploaded file's name, read only for Decision 1's two narrow exceptions.
520
+ */
521
+ export function sniffUpload(data: Uint8Array, name: string): SniffedImage | SniffedFile | SniffRefusal {
522
+ const image = sniffImage(data);
523
+ if (image.ok || image.code === 'no-size') return image;
524
+ return sniffFile(data, name) ?? image;
525
+ }
526
+
527
+ // ── SVG (text) ────────────────────────────────────────────────────────────────
528
+
529
+ /** How far into a text file the root element is looked for. */
530
+ const TEXT_SCAN = 1_000_000;
531
+
532
+ function text(data: Uint8Array): SniffedImage | SniffRefusal {
533
+ const head = data.subarray(0, Math.min(data.length, TEXT_SCAN));
534
+ if (head.includes(0)) return unknown(); // binary
535
+ let s = new TextDecoder('utf-8', { fatal: false }).decode(head);
536
+ if (s.charCodeAt(0) === 0xfeff) s = s.slice(1);
537
+ let i = 0;
538
+ const skipSpace = () => {
539
+ while (i < s.length && /\s/.test(s[i]!)) i++;
540
+ };
541
+ // The prolog: an XML declaration, processing instructions, comments and a doctype.
542
+ for (let guard = 0; guard < 10_000; guard++) {
543
+ skipSpace();
544
+ if (s.startsWith('<?', i)) {
545
+ const end = s.indexOf('?>', i + 2);
546
+ if (end < 0) return unknown();
547
+ i = end + 2;
548
+ } else if (s.startsWith('<!--', i)) {
549
+ const end = s.indexOf('-->', i + 4);
550
+ if (end < 0) return unknown();
551
+ i = end + 3;
552
+ } else if (s.slice(i, i + 9).toUpperCase() === '<!DOCTYPE') {
553
+ // Skipped, internal subset and all: nothing in it is ever read, let alone resolved.
554
+ let depth = 0;
555
+ let j = i + 9;
556
+ for (; j < s.length; j++) {
557
+ const c = s[j];
558
+ if (c === '[') depth++;
559
+ else if (c === ']') depth--;
560
+ else if (c === '>' && depth <= 0) break;
561
+ }
562
+ if (j >= s.length) return unknown();
563
+ if (/^<!DOCTYPE\s+html/i.test(s.slice(i, i + 20))) return named('a web page');
564
+ i = j + 1;
565
+ } else break;
566
+ }
567
+ const root = /^<((?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*))(?=[\s/>])/.exec(s.slice(i, i + 256));
568
+ if (!root) return unknown();
569
+ const local = root[2]!.toLowerCase();
570
+ if (local === 'html') return named('a web page');
571
+ if (root[2] !== 'svg') return unknown();
572
+ // The root's attributes, up to its first unquoted `>`.
573
+ const attrs: Record<string, string> = {};
574
+ const re = /\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/y;
575
+ let j = i + root[0].length;
576
+ for (let guard = 0; guard < 1000; guard++) {
577
+ re.lastIndex = j;
578
+ const m = re.exec(s);
579
+ if (!m) break;
580
+ attrs[m[1]!] = m[2] ?? m[3] ?? '';
581
+ j = re.lastIndex;
582
+ }
583
+ if (!/^\s*\/?>/.test(s.slice(j, j + 64))) return unknown();
584
+ const viewBox = attrs.viewBox?.trim().split(/[\s,]+/).map(Number);
585
+ if (viewBox && viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2]! > 0 && viewBox[3]! > 0) {
586
+ return image('svg', Math.max(1, Math.round(viewBox[2]!)), Math.max(1, Math.round(viewBox[3]!)));
587
+ }
588
+ const px = (v: string | undefined) => {
589
+ const m = v?.trim().match(/^(\d+(?:\.\d+)?)(?:px)?$/);
590
+ return m ? Math.max(1, Math.round(Number(m[1]))) : NaN;
591
+ };
592
+ const w = px(attrs.width);
593
+ const h = px(attrs.height);
594
+ if (w > 0 && h > 0) return image('svg', w, h);
595
+ return refuse('no-size', SVG_NO_SIZE_MESSAGE);
596
+ }
597
+
598
+ /** Does the 50-megapixel cap apply? Not to an SVG: it's drawn at any size, and imgproxy passes it through. */
599
+ export const countsPixels = (format: ImageFormat) => format !== 'svg';