Commit d5f9f21

HPCesia <me@hpcesia.com>
2026-09-09 08:51:15
Extract inline base64 images
Assisted-by: pi-coding-agent:deepseek-v4-flash-vision-exp
1 parent 9f4f2a2
Changed files (6)
src
contents
_lib
components
posts
integrations
styles
article
src/contents/_lib/components/frame.typ
@@ -0,0 +1,14 @@
+#import "@preview/bullseye:0.1.0": match-target
+
+/// Frame content as an SVG image in HTML output (like `#html.frame`),
+/// but keeps the original content in PDF output.
+///
+/// - content (content):
+/// -> content
+#let frame(content) = context {
+  let render = match-target(
+    html: () => html.frame(content),
+    paged: () => content,
+  )
+  render()
+}
src/contents/_lib/lib.typ
@@ -1,5 +1,6 @@
 #import "@preview/bullseye:0.1.0": match-target
 
+#import "./components/frame.typ": frame
 #import "./components/image.typ": img
 #import "./components/ruby.typ": ruby
 #import "./components/sidenote.typ": sidenote
src/contents/posts/typst-syntax.typ
@@ -165,6 +165,21 @@ Pass a `path` value or a root-absolute string to ```typ #kp.img()``` and Astro's
 Or just use Typst's original ```typ #image()``` function to avoid Astro's optimization.
 For web image, use ```typ #html.img()``` instead.
 
+Images that can't be imported as a file are extracted to `/_typst/` at build time.
+
+#figure(
+  image(read("../avatar.png", encoding: none), alt: "HPCesia, author of Kerplunk's avatar", width: 30%),
+  caption: [An image built from bytes.],
+)
+
+#figure(
+  kp.frame({
+    import "@preview/lilaq:0.5.0" as lq
+    lq.diagram(lq.plot(lq.linspace(0, 10), x => calc.sin(x)))
+  }),
+  caption: [A function plot rendered as SVG by ```typ #kp.frame()```.],
+)
+
 = Extra Components
 
 These components are added by Kerplunk.
src/integrations/extract-images.ts
@@ -0,0 +1,126 @@
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import type { AstroIntegration, AstroIntegrationLogger } from "astro";
+
+import { joinUrl } from "../utils/url.ts";
+
+const ASSET_DIR = "_typst";
+
+// Typst's HTML export inlines images (including generated SVGs) as data URLs.
+// After `astro build`, pull them out into files so pages don't carry megabytes
+// of base64.
+
+const EXT_BY_MIME: Record<string, string> = {
+  png: "png",
+  jpeg: "jpg",
+  jpg: "jpg",
+  gif: "gif",
+  webp: "webp",
+  "svg+xml": "svg",
+  avif: "avif",
+  tiff: "tiff",
+  "x-icon": "ico",
+};
+
+const IMG_TAG = /<img\b[^>]*>/g;
+const IMG_SRC = /src=(["'])(data:image\/[^"']+)\1/;
+
+function imageInfo(
+  src: string,
+): { bytes: Buffer; filename: string } | null {
+  const match = src.match(/^data:image\/([^;]+);base64,([A-Za-z0-9+/=]+)$/);
+  if (!match) return null;
+  const ext = EXT_BY_MIME[match[1]];
+  if (!ext) return null;
+  const bytes = Buffer.from(match[2], "base64");
+  const hash = crypto.createHash("sha256").update(bytes).digest("hex").slice(
+    0,
+    12,
+  );
+  return { bytes, filename: `${hash}.${ext}` };
+}
+
+async function collectImages(
+  rootDir: string,
+  onHtml: (filePath: string) => Promise<void>,
+): Promise<void> {
+  for (const entry of await fs.readdir(rootDir, { withFileTypes: true })) {
+    const fullPath = path.join(rootDir, entry.name);
+    if (entry.isDirectory()) {
+      await collectImages(fullPath, onHtml);
+    } else if (entry.isFile() && entry.name.endsWith(".html")) {
+      await onHtml(fullPath);
+    }
+  }
+}
+
+export function extractImages(): AstroIntegration {
+  let base = "/";
+
+  return {
+    name: "extract-images",
+    hooks: {
+      "astro:config:done": ({ config }) => {
+        base = config.base;
+      },
+      "astro:build:done": async ({ dir, logger }) => {
+        const outDir = path.join(fileURLToPath(dir), ASSET_DIR);
+        const written = new Map<string, Buffer>();
+
+        const rewrite = (
+          html: string,
+          filePath: string,
+          logger: AstroIntegrationLogger,
+        ) =>
+          html.replace(IMG_TAG, (tag) => {
+            const srcMatch = tag.match(IMG_SRC);
+            if (!srcMatch) return tag;
+            const info = imageInfo(srcMatch[2]);
+            if (!info) {
+              logger.warn(
+                `[${filePath}] keeping data:image as-is (non-base64 or unknown mime)`,
+              );
+              return tag;
+            }
+            written.set(info.filename, info.bytes);
+            let out = tag.replace(
+              IMG_SRC,
+              `src=${srcMatch[1]}${joinUrl(base, ASSET_DIR, info.filename)}${
+                srcMatch[1]
+              }`,
+            );
+            if (!/\sloading=/.test(out)) {
+              out = out.replace(
+                /^<img\b/,
+                '<img loading="lazy" decoding="async"',
+              );
+            }
+            return out;
+          });
+
+        await collectImages(fileURLToPath(dir), async (filePath) => {
+          const html = await fs.readFile(filePath, "utf-8");
+          const rewritten = rewrite(html, filePath, logger);
+          if (rewritten !== html) {
+            await fs.writeFile(filePath, rewritten);
+          }
+        });
+
+        if (written.size > 0) {
+          await fs.mkdir(outDir, { recursive: true });
+          await Promise.all(
+            [...written].map(([filename, bytes]) =>
+              fs.writeFile(path.join(outDir, filename), bytes)
+            ),
+          );
+          logger.info(
+            `Extracted ${written.size} inlined images to /${ASSET_DIR}`,
+          );
+        }
+      },
+    },
+  };
+}
src/styles/article/index.scss
@@ -16,4 +16,8 @@ article {
       width: calc(4/3 * 100%);
     }
   }
+
+  img:not([data-astro-image]) {
+    height: auto;
+  }
 }
astro.config.ts
@@ -4,6 +4,7 @@ import { typst } from "astro-typst";
 
 import { SITE_CONFIG } from "./src/config.ts";
 
+import { extractImages } from "./src/integrations/extract-images.ts";
 import { rehypeCleanEndnotes } from "./src/utils/plugins/clean-endnotes.ts";
 
 const protocol = SITE_CONFIG.website.split("://")[0];
@@ -25,6 +26,8 @@ export default defineConfig({
       },
       target: "html",
     }),
+    // Order-independent: only hooks into `astro:build:done`.
+    extractImages(),
   ],
   markdown: {
     // NOTE: `markdown.rehypePlugins` is deprecated in Astro v7.