Commit 5c53bc5

HPCesia <me@hpcesia.com>
2026-03-10 21:54:12
Extract Base64 images generated by Typst
1 parent 70bed8d
Changed files (8)
src
contents
_lib
posts
integrations
styles
article
src/contents/_lib/components/frame.typ
@@ -0,0 +1,12 @@
+#import "@preview/bullseye:0.1.0": target
+
+/// Same to `#html.frame`, but it will return content if the target is not html.
+///
+/// - content (content):
+/// -> content
+#let frame(content) = context {
+  if target() == "html" {
+    return html.frame(content)
+  }
+  return content
+}
src/contents/_lib/tybios.typ
@@ -1,3 +1,4 @@
+#import "components/frame.typ": frame
 #import "components/fullwidth.typ": fullwidth
 #import "components/marginnote.typ": marginnote
 
src/contents/posts/test.typ
@@ -72,6 +72,25 @@ And some inline code: `print("Hello, World!")`.
 
 Maybe you want Typst's special inline code with syntax highlighting: ```rust println!("Hello, World!"); ```
 
+= Frame
+
+You can use ```typ #tybios.frame()``` wrap any content that can't automatically convert to HTML, convert them to SVGs.
+#footnote[Converted SVGs will adapt to dark theme by color invert and hue rotation, but the result may not be perfect.]
+This function is a wrapper of #link("https://typst.app/docs/reference/html/frame/", ```typ #html.frame()```),
+but only use it when ```typ #target()``` exists and equals to "html". Otherwise, it will return the content directly without wrapping it in a frame.
+
+#figure(
+  {
+    import "@preview/lilaq:0.5.0" as lq
+    let x = lq.linspace(0, 10)
+
+    tybios.frame(lq.diagram(
+      lq.plot(x, x => calc.sin(x)),
+    ))
+  },
+  caption: [The diagram of the sine function],
+)
+
 = List & Enumeration
 
 Example of enumeration:
src/integrations/extract-image.ts
@@ -0,0 +1,90 @@
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { z } from "astro/zod";
+import { defineIntegration } from "astro-integration-kit";
+import * as cheerio from "cheerio";
+
+// TODO: After Typst 0.15 published, use jsx and path to auto optimize images in typst
+// See https://github.com/typst/typst/pull/7555
+// It's not possible now because image path is string in Typst 0.14,
+// so we can't resolve images' actual path to import them by Astro's `<Image />` component.
+export const extractImage = defineIntegration({
+  name: "tybios-extract-image",
+  optionsSchema: z.object({
+    dir: z.string().optional().default("assets/images/typst"),
+  }),
+  setup({ options }) {
+    return {
+      hooks: {
+        "astro:build:done": async ({ dir, logger }) => {
+          const rootDir = fileURLToPath(dir);
+          const outDir = path.join(rootDir, options.dir);
+          await fs.mkdir(outDir, { recursive: true });
+
+          const seenImages = new Set<string>();
+
+          async function processDirectory(currentDir: string) {
+            const entries = await fs.readdir(currentDir, {
+              withFileTypes: true,
+            });
+            for (const entry of entries) {
+              const fullPath = path.join(currentDir, entry.name);
+              if (entry.isDirectory()) {
+                await processDirectory(fullPath);
+              } else if (entry.isFile() && entry.name.endsWith(".html")) {
+                const html = await fs.readFile(fullPath, "utf-8");
+                const $ = cheerio.load(html);
+                let htmlChanged = false;
+
+                const articleImages = $("article img");
+
+                for (let i = 0; i < articleImages.length; i++) {
+                  const el = articleImages[i];
+                  const src = $(el).attr("src") || "";
+
+                  const match = src.match(/^data:image\/([^;]+);base64,(.+)$/);
+                  if (!match) {
+                    continue;
+                  }
+
+                  const ext = match[1] === "svg+xml" ? "svg" : match[1];
+                  const base64Data = match[2];
+
+                  const hash = crypto
+                    .createHash("sha256")
+                    .update(base64Data)
+                    .digest("hex")
+                    .slice(0, 10);
+                  const filename = `${hash}.${ext}`;
+
+                  if (!seenImages.has(filename)) {
+                    seenImages.add(filename);
+                    const imageBuffer = Buffer.from(base64Data, "base64");
+                    await fs.writeFile(
+                      path.join(outDir, filename),
+                      imageBuffer,
+                    );
+                  }
+
+                  $(el).attr("src", `/${options.dir}/${filename}`);
+                  htmlChanged = true;
+                }
+
+                if (htmlChanged) {
+                  await fs.writeFile(fullPath, $.html());
+                }
+              }
+            }
+          }
+
+          await processDirectory(rootDir);
+          logger.info(
+            `Extracted ${seenImages.size} base64 images to ${options.dir}`,
+          );
+        },
+      },
+    };
+  },
+});
src/styles/article/index.css
@@ -46,4 +46,10 @@ article,
 article {
   @apply text-base lg:text-lg;
   @apply max-w-full md:max-w-2/3;
+
+  img.typst-frame {
+    @variant dark {
+      filter: invert(88%) hue-rotate(180deg);
+    }
+  }
 }
astro.config.ts
@@ -1,7 +1,8 @@
 import tailwindcss from "@tailwindcss/vite";
 import { defineConfig } from "astro/config";
-import { SITE_CONFIG } from "./src/config";
 import { typst } from "astro-typst";
+import { SITE_CONFIG } from "./src/config";
+import { extractImage } from "./src/integrations/extract-image";
 import { rehypeCleanEndnotes } from "./src/plugins/rehype-clean-endnotes";
 
 // https://astro.build/config
@@ -14,6 +15,9 @@ export default defineConfig({
       },
       target: "html",
     }),
+    extractImage({
+      dir: "assets/images/typst",
+    }),
   ],
   markdown: {
     rehypePlugins: [rehypeCleanEndnotes],
package.json
@@ -15,13 +15,16 @@
     "@myriaddreamin/typst.ts": "0.7.0-rc2",
     "@tailwindcss/vite": "^4.2.1",
     "astro": "^6.0.1",
+    "astro-integration-kit": "^0.19.1",
     "astro-typst": "^0.12.3",
+    "cheerio": "^1.2.0",
     "luxon": "^3.7.2",
     "tailwindcss": "^4.2.1",
     "unist-util-visit": "^5.1.0"
   },
   "devDependencies": {
     "@biomejs/biome": "^2.4.6",
-    "@types/luxon": "^3.7.1"
+    "@types/luxon": "^3.7.1",
+    "@types/node": "^25.4.0"
   }
 }
pnpm-lock.yaml
@@ -22,13 +22,19 @@ importers:
         version: 0.7.0-rc2(@myriaddreamin/typst-ts-renderer@0.7.0-rc2)
       '@tailwindcss/vite':
         specifier: ^4.2.1
-        version: 4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))
+        version: 4.2.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1))
       astro:
         specifier: ^6.0.1
-        version: 6.0.1(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3)
+        version: 6.0.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3)
+      astro-integration-kit:
+        specifier: ^0.19.1
+        version: 0.19.1(astro@6.0.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3))
       astro-typst:
         specifier: ^0.12.3
-        version: 0.12.3(@myriaddreamin/typst-ts-node-compiler@0.7.0-rc2)(@myriaddreamin/typst-ts-renderer@0.7.0-rc2)(@myriaddreamin/typst.ts@0.7.0-rc2(@myriaddreamin/typst-ts-renderer@0.7.0-rc2))(astro@6.0.1(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3))(cheerio@1.2.0)(rollup@4.59.0)
+        version: 0.12.3(@myriaddreamin/typst-ts-node-compiler@0.7.0-rc2)(@myriaddreamin/typst-ts-renderer@0.7.0-rc2)(@myriaddreamin/typst.ts@0.7.0-rc2(@myriaddreamin/typst-ts-renderer@0.7.0-rc2))(astro@6.0.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3))(cheerio@1.2.0)(rollup@4.59.0)
+      cheerio:
+        specifier: ^1.2.0
+        version: 1.2.0
       luxon:
         specifier: ^3.7.2
         version: 3.7.2
@@ -45,6 +51,9 @@ importers:
       '@types/luxon':
         specifier: ^3.7.1
         version: 3.7.1
+      '@types/node':
+        specifier: ^25.4.0
+        version: 25.4.0
 
 packages:
 
@@ -863,6 +872,9 @@ packages:
   '@types/nlcst@2.0.3':
     resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
 
+  '@types/node@25.4.0':
+    resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==}
+
   '@types/resolve@1.20.2':
     resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
 
@@ -1853,6 +1865,9 @@ packages:
   uncrypto@0.1.3:
     resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
 
+  undici-types@7.18.2:
+    resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+
   undici@7.22.0:
     resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
     engines: {node: '>=20.18.1'}
@@ -2668,12 +2683,12 @@ snapshots:
       '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
       '@tailwindcss/oxide-win32-x64-msvc': 4.2.1
 
-  '@tailwindcss/vite@4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))':
+  '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1))':
     dependencies:
       '@tailwindcss/node': 4.2.1
       '@tailwindcss/oxide': 4.2.1
       tailwindcss: 4.2.1
-      vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)
+      vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)
 
   '@types/debug@4.1.12':
     dependencies:
@@ -2703,6 +2718,10 @@ snapshots:
     dependencies:
       '@types/unist': 3.0.3
 
+  '@types/node@25.4.0':
+    dependencies:
+      undici-types: 7.18.2
+
   '@types/resolve@1.20.2': {}
 
   '@types/unist@2.0.11': {}
@@ -2730,19 +2749,19 @@ snapshots:
 
   astring@1.9.0: {}
 
-  astro-integration-kit@0.19.1(astro@6.0.1(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3)):
+  astro-integration-kit@0.19.1(astro@6.0.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3)):
     dependencies:
-      astro: 6.0.1(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3)
+      astro: 6.0.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3)
       pathe: 1.1.2
 
-  astro-typst@0.12.3(@myriaddreamin/typst-ts-node-compiler@0.7.0-rc2)(@myriaddreamin/typst-ts-renderer@0.7.0-rc2)(@myriaddreamin/typst.ts@0.7.0-rc2(@myriaddreamin/typst-ts-renderer@0.7.0-rc2))(astro@6.0.1(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3))(cheerio@1.2.0)(rollup@4.59.0):
+  astro-typst@0.12.3(@myriaddreamin/typst-ts-node-compiler@0.7.0-rc2)(@myriaddreamin/typst-ts-renderer@0.7.0-rc2)(@myriaddreamin/typst.ts@0.7.0-rc2(@myriaddreamin/typst-ts-renderer@0.7.0-rc2))(astro@6.0.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3))(cheerio@1.2.0)(rollup@4.59.0):
     dependencies:
       '@myriaddreamin/typst-ts-node-compiler': 0.7.0-rc2
       '@myriaddreamin/typst-ts-renderer': 0.7.0-rc2
       '@myriaddreamin/typst.ts': 0.7.0-rc2(@myriaddreamin/typst-ts-renderer@0.7.0-rc2)
       '@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0)
-      astro: 6.0.1(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3)
-      astro-integration-kit: 0.19.1(astro@6.0.1(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3))
+      astro: 6.0.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3)
+      astro-integration-kit: 0.19.1(astro@6.0.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3))
       cheerio: 1.2.0
       rehype-stringify: 10.0.1
       typstx: 0.0.1-beta.1
@@ -2751,7 +2770,7 @@ snapshots:
       - rollup
       - supports-color
 
-  astro@6.0.1(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3):
+  astro@6.0.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3):
     dependencies:
       '@astrojs/compiler': 3.0.0
       '@astrojs/internal-helpers': 0.8.0
@@ -2803,8 +2822,8 @@ snapshots:
       unist-util-visit: 5.1.0
       unstorage: 1.17.4
       vfile: 6.0.3
-      vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)
-      vitefu: 1.1.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))
+      vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)
+      vitefu: 1.1.2(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1))
       xxhash-wasm: 1.1.0
       yargs-parser: 22.0.0
       zod: 4.3.6
@@ -4269,6 +4288,8 @@ snapshots:
 
   uncrypto@0.1.3: {}
 
+  undici-types@7.18.2: {}
+
   undici@7.22.0: {}
 
   unified@11.0.5:
@@ -4359,7 +4380,7 @@ snapshots:
       '@types/unist': 3.0.3
       vfile-message: 4.0.3
 
-  vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1):
+  vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1):
     dependencies:
       esbuild: 0.27.3
       fdir: 6.5.0(picomatch@4.0.3)
@@ -4368,13 +4389,14 @@ snapshots:
       rollup: 4.59.0
       tinyglobby: 0.2.15
     optionalDependencies:
+      '@types/node': 25.4.0
       fsevents: 2.3.3
       jiti: 2.6.1
       lightningcss: 1.31.1
 
-  vitefu@1.1.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)):
+  vitefu@1.1.2(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)):
     optionalDependencies:
-      vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)
+      vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)
 
   web-namespaces@2.0.1: {}