@wxxb789/anc 0.0.1 → 0.0.3

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Render an accepted static SVG to the one representation this build publishes:
3
+ * lossless WebP with a transparent background.
4
+ *
5
+ * The input is **bytes**, never a path. Sharp's SVG loader is libvips' `svgload`,
6
+ * which hands librsvg a memory buffer rather than a file, so librsvg has no base
7
+ * URI against which to resolve a relative reference. That is a security
8
+ * assumption, not a substitute for the policy: `tests/svg-security.test.ts`
9
+ * renders a document that bypasses the policy entirely and proves neither a host
10
+ * canary nor a listening socket is touched. Both layers are deliberate, and the
11
+ * test exercises each one separately.
12
+ *
13
+ * ## Sizing
14
+ *
15
+ * The natural size is **measured**, not assumed. A metadata read at density 72
16
+ * reports the size librsvg itself chose from the document's own `width`,
17
+ * `height`, `viewBox` and units; sharp's `limitInputPixels` bounds that read, so
18
+ * a declared 1,000,000-pixel square is refused before a surface is allocated.
19
+ * Two measured facts decide the rest:
20
+ *
21
+ * - `px`, unitless, percentage and `em` dimensions scale **linearly** with
22
+ * density (`100px` renders 100 then 200 at 72/144).
23
+ * - physical units scale with the **square** of the density factor, because
24
+ * librsvg evaluates them at the requested DPI and sharp scales again
25
+ * (`100mm` renders 283 then 1134 at 72/144).
26
+ *
27
+ * The author's logical size is taken from `width`/`height` when absolute, with
28
+ * CSS's 96 dpi conversion for physical units, and from the article layout when
29
+ * the document only has a `viewBox`. The render target is 2× that logical size,
30
+ * then clamped proportionally by a finite output-pixel budget and a maximum side.
31
+ * Nothing is cropped and the `viewBox` is never rewritten.
32
+ *
33
+ * ## Output gate
34
+ *
35
+ * The emitted WebP is read back: MIME, one frame, exact target dimensions, the
36
+ * target aspect ratio, and at least one visible pixel. A fully transparent render
37
+ * fails the build — a blank picture is worse than a stopped build. The padding
38
+ * factor in the density request (8% + 4 px) guarantees the rasterizer produces at
39
+ * least the target so the final resize is always a downscale.
40
+ */
41
+ import sharp, {} from 'sharp';
42
+ import { ENCODER_SETTINGS, MEDIA_LIMITS } from "./media-codec.js";
43
+ import { imageDimensions, isPositiveDimension } from "./media.js";
44
+ import { inspectSvg, requiredCharacters } from "./svg-policy.js";
45
+ import { missingCodePoints } from "./font-coverage.js";
46
+ /**
47
+ * The render policy, chosen from the measurements recorded in goal 0012.
48
+ *
49
+ * `logicalWidth` is the article's reading measure rather than a library default:
50
+ * `docs/core-design/media-pipeline.md` fixes a `viewBox`-only picture to the
51
+ * article layout, and the stylesheet's `--measure` resolves to 68 characters —
52
+ * measured at 720 px for the body face on this corpus. 2× of that is 1,440 px,
53
+ * which is what a diagram is rasterized at before any budget clamp.
54
+ *
55
+ * `maxOutputPixels` is 6,000,000 — comfortably above a 1,440-wide diagram and its
56
+ * caption-sized height, and finite so a hostile `viewBox` cannot ask for an
57
+ * unbounded surface. `maxRenderPixels` is four times that, the headroom the
58
+ * density formula's 8% padding and rounding need.
59
+ */
60
+ export const SVG_RENDER_LIMITS = Object.freeze({
61
+ density: 2,
62
+ logicalWidth: 720,
63
+ maxOutputPixels: 6_000_000,
64
+ maxOutputSide: 8_000,
65
+ maxRenderPixels: 24_000_000,
66
+ maxSourceBytes: 8 * 1024 * 1024,
67
+ });
68
+ /** Sharp's own density ceiling, from its input options validation. */
69
+ const SHARP_MAX_DENSITY = 100_000;
70
+ /**
71
+ * Sharp's error text for a libvips budget refusal, across versions.
72
+ *
73
+ * Both the pixel limit and a zero-sized vector surface appear here: a document
74
+ * whose natural size rounds to nothing cannot be rendered at all, and reporting
75
+ * that as a corrupt file would send the author looking for the wrong fault.
76
+ */
77
+ const RENDER_BUDGET_ERROR = /pixel limit|exceeds.*limit|zero-sized|not a valid (?:image|svg)/i;
78
+ /** A rejection this module classifies for the publisher. */
79
+ class SvgRenderError extends Error {
80
+ refusal;
81
+ constructor(refusal) {
82
+ super(refusal.detail);
83
+ this.refusal = refusal;
84
+ }
85
+ }
86
+ /** A rejection, as the publisher's `PrepareResult` spells it. */
87
+ function failed(refusal) {
88
+ return { ok: false, reason: refusal.reason, detail: refusal.detail };
89
+ }
90
+ /** Run one libvips operation under a deadline. */
91
+ async function withinDeadline(work, milliseconds) {
92
+ const timeout = Promise.withResolvers();
93
+ const timer = setTimeout(() => timeout.reject(new SvgRenderError({ reason: 'timeout', detail: 'the operation exceeded its deadline' })), milliseconds);
94
+ try {
95
+ return await Promise.race([work, timeout.promise]);
96
+ }
97
+ finally {
98
+ clearTimeout(timer);
99
+ }
100
+ }
101
+ /** Classify a sharp/libvips failure. */
102
+ function classifyRenderError(error) {
103
+ if (error instanceof SvgRenderError)
104
+ return error.refusal;
105
+ const message = error instanceof Error ? error.message.split('\n')[0] : String(error);
106
+ if (RENDER_BUDGET_ERROR.test(message)) {
107
+ return { reason: 'svg-workload', detail: `the rasterizer refused the render: ${message}` };
108
+ }
109
+ return { reason: 'svg-render', detail: `the rasterizer failed: ${message}` };
110
+ }
111
+ /**
112
+ * The constructor options every operation on untrusted SVG bytes uses.
113
+ *
114
+ * `density` is the only size lever and `limitInputPixels` is never lifted. The
115
+ * options are the same ones the raster path uses, minus animation, because an SVG
116
+ * has one surface. Sharp does not cap input bytes itself, so `prepareSvg` checks
117
+ * the source length before calling.
118
+ */
119
+ function renderOptions(density, limits) {
120
+ return {
121
+ density,
122
+ failOn: 'warning',
123
+ limitInputPixels: limits.maxRenderPixels,
124
+ unlimited: false,
125
+ sequentialRead: true,
126
+ };
127
+ }
128
+ /** The natural size librsvg chooses at 72 dpi, bounded by the render budget. */
129
+ async function naturalSize(bytes, limits) {
130
+ try {
131
+ const metadata = await withinDeadline(sharp(bytes, renderOptions(72, limits)).metadata(), MEDIA_LIMITS.decodeTimeoutMs);
132
+ if (!isPositiveDimension(metadata.width) || !isPositiveDimension(metadata.height)) {
133
+ return { reason: 'svg-invalid', detail: 'the rasterizer reports no positive size for this document' };
134
+ }
135
+ return { width: metadata.width, height: metadata.height };
136
+ }
137
+ catch (error) {
138
+ return classifyRenderError(error);
139
+ }
140
+ }
141
+ /**
142
+ * The logical size the published picture should have, in CSS pixels.
143
+ *
144
+ * An absolute author size wins; a `viewBox`-only document is laid out at the
145
+ * article width with its own aspect ratio. The natural size supplies the aspect
146
+ * for a document that declares only one side.
147
+ */
148
+ function logicalSize(source, natural, limits) {
149
+ const aspect = natural.width / natural.height;
150
+ if (source.declaredSize) {
151
+ const width = source.width ?? source.height * aspect;
152
+ const height = source.height ?? source.width / aspect;
153
+ return { width, height };
154
+ }
155
+ return { width: limits.logicalWidth, height: limits.logicalWidth / aspect };
156
+ }
157
+ /**
158
+ * The output size: 2× logical, then proportionally clamped.
159
+ *
160
+ * Floor rather than round, so the product can only shrink; `Math.max(1, …)`
161
+ * keeps a degenerate aspect from producing a zero-pixel side. There is no crop
162
+ * and no independent axis decision — both sides take one scale factor.
163
+ */
164
+ function outputSize(logical, limits) {
165
+ const desiredWidth = logical.width * limits.density;
166
+ const desiredHeight = logical.height * limits.density;
167
+ const scale = Math.min(1, Math.sqrt(limits.maxOutputPixels / (desiredWidth * desiredHeight)), limits.maxOutputSide / Math.max(desiredWidth, desiredHeight));
168
+ return {
169
+ width: Math.max(1, Math.floor(desiredWidth * scale)),
170
+ height: Math.max(1, Math.floor(desiredHeight * scale)),
171
+ };
172
+ }
173
+ /**
174
+ * Whether every visible character has a host glyph.
175
+ *
176
+ * Pango draws a hex box containing the code point when no font covers it, so a
177
+ * missing label is neither blank nor pixel-comparable across code points: measured
178
+ * on this host, U+10FFF0 and U+E000 render boxes reading `10FFF0` and `E000`.
179
+ * `font-coverage.ts` therefore asks the same question fontconfig answers — is the
180
+ * code point in any installed font's `cmap` — and this function turns its answer
181
+ * into the private diagnostic.
182
+ */
183
+ function fontCoverageRefusal(characters) {
184
+ if (characters.length === 0)
185
+ return undefined;
186
+ const codePoints = characters.map((character) => character.codePointAt(0));
187
+ const coverage = missingCodePoints(codePoints);
188
+ if (coverage.missing.length === 0)
189
+ return undefined;
190
+ const labels = coverage.missing
191
+ .slice(0, 5)
192
+ .map((codePoint) => `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`);
193
+ const more = coverage.missing.length > labels.length ? ` and ${coverage.missing.length - labels.length} more` : '';
194
+ const fonts = coverage.fonts === 0
195
+ ? 'no font files were found on this host'
196
+ : `${coverage.fonts} installed font files were searched`;
197
+ return {
198
+ reason: 'svg-font',
199
+ detail: `no installed font covers ${labels.join(', ')}${more}; ${fonts}. Install a font with those glyphs or convert the labels to outlines.`,
200
+ };
201
+ }
202
+ /** The render density that makes librsvg produce at least `targetWidth` pixels. */
203
+ function densityFor(targetWidth, natural, source) {
204
+ if (source.sizeScale === 'physical') {
205
+ // render = natural × (density / 72)²
206
+ return 72 * Math.sqrt(targetWidth / natural.width);
207
+ }
208
+ return (72 * targetWidth) / natural.width;
209
+ }
210
+ /**
211
+ * Render one accepted SVG document and publish its lossless WebP bytes.
212
+ *
213
+ * @param bytes The exact bytes the walk authorized; never re-read from a path.
214
+ */
215
+ export async function prepareSvg(bytes, limits = SVG_RENDER_LIMITS) {
216
+ if (bytes.length > limits.maxSourceBytes) {
217
+ return { ok: false, reason: 'too-large', detail: `the SVG is larger than ${limits.maxSourceBytes} bytes` };
218
+ }
219
+ const inspection = inspectSvg(bytes);
220
+ if (!inspection.ok)
221
+ return failed(inspection.refusal);
222
+ const source = inspection.source;
223
+ const natural = await naturalSize(bytes, limits);
224
+ if ('reason' in natural)
225
+ return failed(natural);
226
+ const characters = requiredCharacters(source);
227
+ if (!Array.isArray(characters))
228
+ return failed(characters);
229
+ const coverage = fontCoverageRefusal(characters);
230
+ if (coverage !== undefined)
231
+ return failed(coverage);
232
+ const logical = logicalSize(source, natural, limits);
233
+ const output = outputSize(logical, limits);
234
+ const logicalAspect = logical.width / logical.height;
235
+ // Flooring each side can cost at most one pixel, and the target is the
236
+ // author's logical size — not the 72-dpi measurement, whose physical-unit
237
+ // rounding is a coarse approximation that grows with the output. A genuine
238
+ // shape change misses both bounds by far more than one pixel.
239
+ const heightError = Math.abs(output.height - output.width / logicalAspect);
240
+ const widthError = Math.abs(output.width - output.height * logicalAspect);
241
+ if (heightError > 1 + output.height * 0.01 && widthError > 1 + output.width * 0.01) {
242
+ return failed({
243
+ reason: 'svg-workload',
244
+ detail: `the clamped output ${output.width}×${output.height} would change the document's aspect ratio`,
245
+ });
246
+ }
247
+ // Over-render by 8% + 4 px so the final resize is always a downscale; rounding
248
+ // in librsvg's own size report cannot turn the target into an upscale.
249
+ const targetRenderWidth = output.width * 1.08 + 4;
250
+ const density = densityFor(targetRenderWidth, natural, source);
251
+ if (!Number.isFinite(density) || density < 1 || density > SHARP_MAX_DENSITY) {
252
+ return failed({
253
+ reason: 'svg-workload',
254
+ detail: `a density of ${density.toFixed(2)} is outside sharp's 1–${SHARP_MAX_DENSITY} range`,
255
+ });
256
+ }
257
+ const predictedRenderPixels = targetRenderWidth * targetRenderWidth * (natural.height / natural.width);
258
+ if (predictedRenderPixels > limits.maxRenderPixels) {
259
+ return failed({
260
+ reason: 'svg-workload',
261
+ detail: `the render surface would exceed ${limits.maxRenderPixels} pixels`,
262
+ });
263
+ }
264
+ let encoded;
265
+ try {
266
+ encoded = await withinDeadline(sharp(bytes, renderOptions(density, limits))
267
+ .resize(output.width, output.height, { fit: 'fill' })
268
+ .ensureAlpha()
269
+ .webp({ lossless: true, ...ENCODER_SETTINGS.lossless })
270
+ .toBuffer(), MEDIA_LIMITS.encodeTimeoutMs);
271
+ }
272
+ catch (error) {
273
+ return failed(classifyRenderError(error));
274
+ }
275
+ if (encoded.length === 0) {
276
+ return { ok: false, reason: 'encode-failed', detail: 'the encoder returned no bytes' };
277
+ }
278
+ let emitted;
279
+ try {
280
+ emitted = await withinDeadline(sharp(encoded).metadata(), MEDIA_LIMITS.decodeTimeoutMs);
281
+ }
282
+ catch (error) {
283
+ return failed(classifyRenderError(error));
284
+ }
285
+ if (emitted.format !== 'webp') {
286
+ return { ok: false, reason: 'encode-failed', detail: `the encoder emitted ${emitted.format}, not WebP` };
287
+ }
288
+ if (emitted.width !== output.width || emitted.height !== output.height) {
289
+ return {
290
+ ok: false,
291
+ reason: 'encode-failed',
292
+ detail: `the encoder emitted ${emitted.width}×${emitted.height}, not ${output.width}×${output.height}`,
293
+ };
294
+ }
295
+ if ((emitted.pages ?? 1) > 1) {
296
+ return { ok: false, reason: 'encode-failed', detail: 'the encoder emitted an animation' };
297
+ }
298
+ if (emitted.orientation !== undefined && emitted.orientation !== 1) {
299
+ return { ok: false, reason: 'encode-failed', detail: 'the encoder emitted a rotated image' };
300
+ }
301
+ // WebP lossless drops the alpha channel when every pixel is opaque, so a
302
+ // decoded image with three channels is fully visible. A decoded image with four
303
+ // channels whose alpha mean is zero is a render that drew nothing — an
304
+ // intentionally empty SVG is refused here rather than published as a blank.
305
+ // Only an image that carries alpha needs the extra decode.
306
+ if (emitted.hasAlpha === true) {
307
+ let stats;
308
+ try {
309
+ stats = await withinDeadline(sharp(encoded).stats(), MEDIA_LIMITS.decodeTimeoutMs);
310
+ }
311
+ catch (error) {
312
+ return failed(classifyRenderError(error));
313
+ }
314
+ const alpha = stats.channels[3];
315
+ if (alpha === undefined || alpha.mean === 0) {
316
+ return failed({ reason: 'svg-empty', detail: 'every rendered pixel is fully transparent' });
317
+ }
318
+ }
319
+ // 0010's reader owns the published pair, and it reads the emitted bytes. The
320
+ // layout size is the emitted pair divided by the target density: a 600-pixel
321
+ // diagram is published as a 1,200-pixel WebP and laid out at 600 CSS pixels, so
322
+ // a 2× display uses the extra pixels instead of doubling the picture. When the
323
+ // output budget clamps the raster, the layout shrinks with it (the emitted
324
+ // pair / 2), never upscales it.
325
+ const dimensions = imageDimensions(encoded, 'webp');
326
+ if (dimensions === undefined) {
327
+ return { ok: false, reason: 'encode-failed', detail: 'the emitted WebP declares no readable size' };
328
+ }
329
+ const displayDimensions = {
330
+ width: Math.max(1, Math.round(dimensions.width / limits.density)),
331
+ height: Math.max(1, Math.round(dimensions.height / limits.density)),
332
+ };
333
+ return {
334
+ ok: true,
335
+ image: {
336
+ bytes: encoded,
337
+ format: 'webp',
338
+ dimensions,
339
+ displayDimensions,
340
+ transcoded: true,
341
+ },
342
+ };
343
+ }
@@ -32,7 +32,7 @@ import Layout from '../../layouts/Layout.astro';
32
32
  import LinkedNotes from '../../components/LinkedNotes.astro';
33
33
  import NoteGraph from '../../components/NoteGraph.astro';
34
34
  import TableOfContents from '../../components/TableOfContents.astro';
35
- import { entries, getEntry, tagFacets, tagRouteForLabel } from '../../lib/content.js';
35
+ import { assetDimensions, entries, getEntry, publishedAssets, tagFacets, tagRouteForLabel } from '../../lib/content.js';
36
36
  import { hasDrawableGraph, localGraph } from '../../lib/graph.js';
37
37
  import { renderMarkdown } from '../../lib/markdown.js';
38
38
  import {
@@ -98,6 +98,15 @@ const lang = entry.language ?? NAV_LANGUAGE;
98
98
  const { html, toc, hasCode, hasMath, hasMermaid } = await renderMarkdown(entry.markdown, {
99
99
  pageTitle: entry.title,
100
100
  routeForSlug: (slug) => (getEntry(slug) ? noteRoute(slug) : undefined),
101
+ // The renderer's `img` transform refuses a local `src` this build did not
102
+ // publish, so the page hands it the artifact's own asset list. Passing the
103
+ // list rather than letting the renderer infer one is what makes "the producer
104
+ // authorized it" the only way an image reaches a reader.
105
+ publishedAssets,
106
+ // The producer's header-read pixel sizes, so the browser reserves a figure's
107
+ // layout space before its bytes arrive. Absent for an asset whose header did
108
+ // not yield a size; the renderer then omits the attributes rather than guess.
109
+ assetDimensions,
101
110
  // The two strings the renderer writes into the article that are this
102
111
  // repository's words rather than the author's: a heading anchor's accessible
103
112
  // name and an untitled diagram's caption. They are chrome, so they follow the
@@ -28,7 +28,10 @@
28
28
  * reaches a third party. Measured, not inferred. The page therefore says
29
29
  * "script, stylesheet, and font" where it once said "every asset", and names the
30
30
  * image case separately: the earlier wording was a claim this build does not
31
- * keep, on the page a reader consults to calibrate trust.
31
+ * keep, on the page a reader consults to calibrate trust. An image published
32
+ * from this site's own files (Goal 0009) is `'self'` and is not a third-party
33
+ * case at all, which is why the sentence below scopes the exception to an image
34
+ * "hosted elsewhere".
32
35
  *
33
36
  * The other thing this page cannot speak for is the host that serves the files,
34
37
  * so it says who that is and stops rather than promising on their behalf.
@@ -76,10 +79,11 @@ import { TITLE_SUFFIX } from '../lib/site.js';
76
79
  </p>
77
80
 
78
81
  <p>
79
- Images are the exception, and only where a note's author linked one. A note that embeds an
80
- image hosted elsewhere renders that image from wherever it lives, so displaying it tells that
81
- host you loaded the page — the same as following a link there. Nothing on this site does that
82
- on its own; it happens only where the writing itself points outward.
82
+ An image published from this site's own files is served from this address like everything
83
+ else. Images are the exception only where a note's author linked one hosted elsewhere: that
84
+ image renders from wherever it lives, so displaying it tells that host you loaded the page —
85
+ the same as following a link there. Nothing on this site does that on its own; it happens
86
+ only where the writing itself points outward.
83
87
  </p>
84
88
 
85
89
  <p>
@@ -655,6 +655,35 @@ main:focus:not(:focus-visible) {
655
655
  }
656
656
  }
657
657
 
658
+ /*
659
+ * A captioned block image. `<figure>` carries a UA `margin-inline: 40px` that
660
+ * would indent the picture away from the prose, so the margin is set explicitly
661
+ * like the code-block and diagram figures. `min-width: 0` keeps a wide image
662
+ * from forcing the article's grid wider; the image itself is already capped by
663
+ * the global `max-width: 100%; height: auto` rule, so its intrinsic
664
+ * `width`/`height` attributes reserve the right aspect ratio without pinning a
665
+ * pixel size that would overflow a narrow viewport.
666
+ */
667
+ .image-figure {
668
+ margin-block: var(--space-6);
669
+ margin-inline: 0;
670
+ min-width: 0;
671
+ }
672
+
673
+ /* A block-level image removes the inline baseline gap; centring keeps a
674
+ portrait or a small transparent illustration from hugging the text edge. */
675
+ .image-figure > img {
676
+ display: block;
677
+ margin-inline: auto;
678
+ }
679
+
680
+ .image-figure > figcaption {
681
+ margin-top: var(--space-2);
682
+ color: var(--color-muted);
683
+ font-size: var(--text-sm);
684
+ text-align: center;
685
+ }
686
+
658
687
  /*
659
688
  * A fenced code block. `<figure>` carries a UA `margin-inline: 40px` that would
660
689
  * indent every code block away from the prose, so the margin is set explicitly.