From 9298f14f4a8260a449ab35e5d3422b4452a986d5 Mon Sep 17 00:00:00 2001
From: wanikua
Date: Wed, 17 Jun 2026 11:14:28 +0800
Subject: [PATCH 1/4] feat: import Markdown (.md) files as study documents
Render an uploaded Markdown file to a clean, text-bearing PDF with pdfkit +
marked up front, then send it through the exact same extract -> quality-gate
-> store pipeline as any PDF. The viewer, concept tagging, knowledge graph,
and all four study tools work on Markdown with no further changes, and the
text-coverage gate applies unchanged.
- lib/md-to-pdf.ts: server-side Markdown -> PDF renderer (headings, ordered
and bullet lists, blockquotes, fenced code, GFM tables, inline emphasis,
inline code and links), styled to match scripts/generate-sample-pdfs.ts
- app/api/upload/route.ts: detect .md/.markdown uploads and convert before
the %PDF- check; empty/oversized markdown returns a friendly 422
- components/UploadCard.tsx: accept Markdown in the file picker, drop zone,
validation, and copy
- package.json: promote marked from a dev to a runtime dependency, since the
converter runs in the packaged app
Latin scripts only for now: the standard PDF fonts cover Latin, while CJK and
other scripts would need an embedded font (a deliberate follow-up).
---
app/api/upload/route.ts | 26 +++
components/UploadCard.tsx | 14 +-
lib/md-to-pdf.ts | 435 ++++++++++++++++++++++++++++++++++++++
package.json | 2 +-
4 files changed, 471 insertions(+), 6 deletions(-)
create mode 100644 lib/md-to-pdf.ts
diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts
index afa6b20..682ccc2 100644
--- a/app/api/upload/route.ts
+++ b/app/api/upload/route.ts
@@ -25,12 +25,20 @@ import {
type PdfRejectReason,
type PdfQualityStats,
} from "@/lib/pdf-extract";
+import {
+ markdownToPdf,
+ MarkdownEmptyError,
+ MarkdownTooLargeError,
+} from "@/lib/md-to-pdf";
import { ensureDocDir, pdfPath } from "@/lib/paths";
import { getDoc, newDocId, saveDoc } from "@/lib/store";
export const runtime = "nodejs";
export const maxDuration = 60;
+/** Extensions we accept as Markdown and render to PDF before ingesting. */
+const MARKDOWN_EXT = /\.(md|markdown|mdown|mkd|mdwn)$/i;
+
const SAMPLE_NAME_TO_DOC_ID: Record = {
anatomy: "sample-anatomy",
physics: "sample-physics",
@@ -105,6 +113,24 @@ export async function POST(req: Request) {
buffer = Buffer.from(await file.arrayBuffer());
const fname = (file as unknown as { name?: string }).name;
if (fname) filename = fname.replace(/[^a-z0-9._-]/gi, "_");
+
+ // Markdown uploads: render to a clean, text-bearing PDF up front, then
+ // fall through to the exact same extract → gate → store pipeline as any
+ // other PDF. The %PDF- sanity check below then validates the rendered
+ // bytes (pdfkit emits a 1.7 header).
+ if (MARKDOWN_EXT.test(filename)) {
+ try {
+ buffer = await markdownToPdf(buffer.toString("utf-8"));
+ } catch (e) {
+ if (e instanceof MarkdownEmptyError || e instanceof MarkdownTooLargeError) {
+ return NextResponse.json({ error: e.message }, { status: 422 });
+ }
+ return NextResponse.json(
+ { error: "This Markdown file couldn't be converted to a document." },
+ { status: 422 },
+ );
+ }
+ }
}
} else {
return NextResponse.json({ error: "expected multipart/form-data" }, { status: 400 });
diff --git a/components/UploadCard.tsx b/components/UploadCard.tsx
index 2b41790..3940c84 100644
--- a/components/UploadCard.tsx
+++ b/components/UploadCard.tsx
@@ -83,6 +83,10 @@ const FILENAME_TO_TITLE: Record = {
"chemistry.pdf": "Organic Chemistry",
};
+/** A PDF, or a Markdown file we render to PDF on upload. Kept in sync with
+ * MARKDOWN_EXT in app/api/upload/route.ts. */
+const ACCEPTED_FILE = /\.(pdf|md|markdown|mdown|mkd|mdwn)$/i;
+
function humaniseAgo(ts: number): string {
const dt = Date.now() - ts;
if (dt < 5_000) return "just now";
@@ -137,8 +141,8 @@ export default function UploadCard() {
const startUpload = useCallback(
async (file: File) => {
setError(null);
- if (!file.name.toLowerCase().endsWith(".pdf")) {
- setError("Please pick a PDF file");
+ if (!ACCEPTED_FILE.test(file.name)) {
+ setError("Please pick a PDF or Markdown (.md) file");
return;
}
setBusy("upload");
@@ -203,7 +207,7 @@ export default function UploadCard() {
{
const f = e.target.files?.[0];
@@ -237,7 +241,7 @@ export default function UploadCard() {
>
) : (
<>
- Drop your PDF here, or
+ Drop your PDF or Markdown here, or
Select the file
@@ -246,7 +250,7 @@ export default function UploadCard() {
)}
- Text-tagged PDFs work best. No OCR.
+ Text-based PDFs and Markdown (.md) work best. No OCR.
diff --git a/lib/md-to-pdf.ts b/lib/md-to-pdf.ts
new file mode 100644
index 0000000..85562b6
--- /dev/null
+++ b/lib/md-to-pdf.ts
@@ -0,0 +1,435 @@
+/**
+ * Server-side Markdown → PDF rendering.
+ *
+ * Get It. is built around a PDF: the viewer renders the document page by
+ * page and overlays concept tags at real PDF-space coordinates, and every
+ * agent reads the per-page text that `lib/pdf-extract.ts` pulls out. To let
+ * students study from a `.md` file without rebuilding any of that, we turn
+ * the markdown into a clean, text-bearing PDF up front and then feed it
+ * through the exact same `extractPdf` pipeline as a normal upload.
+ *
+ * We tokenize with `marked` (already a project dependency) and lay the
+ * tokens out with `pdfkit` (the same library `scripts/generate-sample-pdfs.ts`
+ * uses to mint the bundled sample documents), so this adds no new runtime
+ * dependency. The standard PDF fonts cover Latin scripts; non-Latin scripts
+ * (CJK, etc.) would need an embedded font and are a deliberate follow-up.
+ */
+
+import PDFDocument from "pdfkit";
+import { marked, type Token, type Tokens } from "marked";
+
+/**
+ * Reject absurdly large markdown before we spend time rendering it. The
+ * extracted PDF still has to clear `MAX_PDF_PAGES`, but bailing on the raw
+ * text first keeps a pathological paste from pinning a CPU. ~1M characters
+ * is far longer than any real study document.
+ */
+export const MAX_MARKDOWN_BYTES = 1_000_000;
+
+export class MarkdownEmptyError extends Error {
+ constructor() {
+ super("This Markdown file has no readable text.");
+ this.name = "MarkdownEmptyError";
+ }
+}
+
+export class MarkdownTooLargeError extends Error {
+ constructor() {
+ super("This Markdown file is too large to import.");
+ this.name = "MarkdownTooLargeError";
+ }
+}
+
+// ── Page geometry & palette ─────────────────────────────────────────────
+// A4 in PDF points, with comfortable reading margins. Mirrors the editorial
+// "ink on warm white" look of the bundled samples and the writeup PDF.
+
+const PAGE = { width: 595.28, height: 841.89 };
+const MARGIN = 64;
+const CONTENT_WIDTH = PAGE.width - MARGIN * 2;
+const PAGE_BOTTOM = PAGE.height - MARGIN;
+
+const INK_900 = "#0f172a";
+const INK_700 = "#1e293b";
+const INK_500 = "#64748b";
+const ACCENT = "#4f5ae0";
+const RULE = "#cbd5e1";
+const CODE_BG = "#f3f2ef";
+
+const FONT = {
+ regular: "Helvetica",
+ bold: "Helvetica-Bold",
+ italic: "Helvetica-Oblique",
+ boldItalic: "Helvetica-BoldOblique",
+ mono: "Courier",
+} as const;
+
+/** Point size per heading depth (h1…h6). */
+const HEADING_SIZE = [22, 17, 14, 12.5, 11.5, 11];
+const BODY_SIZE = 11;
+const CODE_SIZE = 9;
+const LINE_GAP = 2.5;
+
+type PDFKitDoc = InstanceType;
+
+/** Inline run after emphasis/link nesting has been flattened to leaves. */
+type Segment = {
+ text: string;
+ bold: boolean;
+ italic: boolean;
+ mono: boolean;
+ link?: string;
+};
+
+type Style = { bold: boolean; italic: boolean };
+
+const BASE_STYLE: Style = { bold: false, italic: false };
+
+/** Decode the handful of HTML entities `marked` leaves encoded in token text. */
+function decodeEntities(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, " ");
+}
+
+/**
+ * Walk an inline token tree (the `tokens` array on a paragraph, heading,
+ * list item, …) and flatten it to a flat list of styled leaf runs that
+ * pdfkit can emit as one continued line.
+ */
+function flattenInline(tokens: Token[] | undefined, style: Style, out: Segment[]): void {
+ if (!tokens) return;
+ for (const token of tokens) {
+ switch (token.type) {
+ case "text": {
+ const t = token as Tokens.Text;
+ if (t.tokens && t.tokens.length) flattenInline(t.tokens, style, out);
+ else out.push({ text: decodeEntities(t.text), bold: style.bold, italic: style.italic, mono: false });
+ break;
+ }
+ case "escape": {
+ const t = token as Tokens.Escape;
+ out.push({ text: t.text, bold: style.bold, italic: style.italic, mono: false });
+ break;
+ }
+ case "strong":
+ flattenInline((token as Tokens.Strong).tokens, { ...style, bold: true }, out);
+ break;
+ case "em":
+ flattenInline((token as Tokens.Em).tokens, { ...style, italic: true }, out);
+ break;
+ case "del":
+ flattenInline((token as Tokens.Del).tokens, style, out);
+ break;
+ case "codespan": {
+ const t = token as Tokens.Codespan;
+ out.push({ text: decodeEntities(t.text), bold: style.bold, italic: style.italic, mono: true });
+ break;
+ }
+ case "link": {
+ const t = token as Tokens.Link;
+ const before = out.length;
+ flattenInline(t.tokens, style, out);
+ for (let i = before; i < out.length; i++) out[i].link = t.href;
+ break;
+ }
+ case "br":
+ out.push({ text: "\n", bold: style.bold, italic: style.italic, mono: false });
+ break;
+ case "image": {
+ // We can't lay out images with the standard pipeline; keep the alt
+ // text so the document still reads and the detector has something.
+ const t = token as Tokens.Image;
+ if (t.text) out.push({ text: t.text, bold: style.bold, italic: style.italic, mono: false });
+ break;
+ }
+ default: {
+ const t = token as { text?: string };
+ if (t.text) out.push({ text: decodeEntities(t.text), bold: style.bold, italic: style.italic, mono: false });
+ }
+ }
+ }
+}
+
+function fontFor(seg: Segment): string {
+ if (seg.mono) return FONT.mono;
+ if (seg.bold && seg.italic) return FONT.boldItalic;
+ if (seg.bold) return FONT.bold;
+ if (seg.italic) return FONT.italic;
+ return FONT.regular;
+}
+
+type EmitOpts = {
+ size: number;
+ color: string;
+ indent?: number;
+ width?: number;
+ paragraphGap?: number;
+};
+
+/** Emit a run of flattened segments as a single wrapped paragraph. */
+function emitSegments(doc: PDFKitDoc, segs: Segment[], opts: EmitOpts): void {
+ const width = opts.width ?? CONTENT_WIDTH - (opts.indent ?? 0);
+ if (segs.length === 0) {
+ doc.text(" ", { width });
+ return;
+ }
+ const last = segs.length - 1;
+ segs.forEach((seg, i) => {
+ doc
+ .font(fontFor(seg))
+ .fontSize(opts.size)
+ .fillColor(seg.link ? ACCENT : opts.color);
+ const textOpts: PDFKit.Mixins.TextOptions = {
+ continued: i < last,
+ width,
+ lineGap: LINE_GAP,
+ };
+ if (i === last) textOpts.paragraphGap = opts.paragraphGap ?? 8;
+ if (seg.link) {
+ textOpts.link = seg.link;
+ textOpts.underline = true;
+ }
+ doc.text(seg.text, textOpts);
+ });
+}
+
+/** Add a page break before a block if it would otherwise be orphaned. */
+function breakIfTight(doc: PDFKitDoc, needed: number): void {
+ if (doc.y + needed > PAGE_BOTTOM) doc.addPage();
+}
+
+// ── Block renderers ─────────────────────────────────────────────────────
+
+function renderHeading(doc: PDFKitDoc, token: Tokens.Heading): void {
+ const size = HEADING_SIZE[Math.min(token.depth, HEADING_SIZE.length) - 1];
+ breakIfTight(doc, size * 2.4);
+ doc.moveDown(token.depth <= 2 ? 0.6 : 0.4);
+ const segs: Segment[] = [];
+ flattenInline(token.tokens, BASE_STYLE, segs);
+ // Headings render in a single weight regardless of inline emphasis.
+ for (const s of segs) s.bold = true;
+ emitSegments(doc, segs, { size, color: INK_900, paragraphGap: 4 });
+ doc.moveDown(0.25);
+}
+
+function renderParagraph(doc: PDFKitDoc, token: Tokens.Paragraph): void {
+ const segs: Segment[] = [];
+ flattenInline(token.tokens, BASE_STYLE, segs);
+ emitSegments(doc, segs, { size: BODY_SIZE, color: INK_700, paragraphGap: 8 });
+}
+
+function renderList(doc: PDFKitDoc, token: Tokens.List, depth = 0): void {
+ const indent = 18 + depth * 16;
+ let index = typeof token.start === "number" ? token.start : 1;
+ for (const item of token.items) {
+ const marker = token.ordered ? `${index}.` : "•";
+ breakIfTight(doc, BODY_SIZE * 2);
+ const markerX = MARGIN + indent - 14;
+ const y = doc.y;
+ doc.font(FONT.regular).fontSize(BODY_SIZE).fillColor(INK_500).text(marker, markerX, y, {
+ width: 14,
+ lineGap: LINE_GAP,
+ });
+ // Render the item's own inline content next to the marker, then recurse
+ // into any nested blocks (sub-lists) underneath it.
+ doc.x = MARGIN + indent;
+ doc.y = y;
+ const inline: Segment[] = [];
+ const nested: Tokens.List[] = [];
+ for (const child of item.tokens) {
+ if (child.type === "list") nested.push(child as Tokens.List);
+ else if (child.type === "text") flattenInline((child as Tokens.Text).tokens ?? [child as Token], BASE_STYLE, inline);
+ else flattenInline([child], BASE_STYLE, inline);
+ }
+ emitSegments(doc, inline, {
+ size: BODY_SIZE,
+ color: INK_700,
+ indent,
+ width: CONTENT_WIDTH - indent,
+ paragraphGap: 3,
+ });
+ doc.x = MARGIN;
+ for (const sub of nested) renderList(doc, sub, depth + 1);
+ index++;
+ }
+ doc.moveDown(0.3);
+}
+
+function renderCode(doc: PDFKitDoc, token: Tokens.Code): void {
+ const code = token.text.replace(/\n+$/, "");
+ doc.font(FONT.mono).fontSize(CODE_SIZE);
+ const innerWidth = CONTENT_WIDTH - 20;
+ const height = doc.heightOfString(code, { width: innerWidth, lineGap: 2 });
+ breakIfTight(doc, height + 16);
+ const top = doc.y;
+ doc
+ .save()
+ .rect(MARGIN, top, CONTENT_WIDTH, height + 14)
+ .fill(CODE_BG)
+ .restore();
+ doc
+ .font(FONT.mono)
+ .fontSize(CODE_SIZE)
+ .fillColor(INK_700)
+ .text(code, MARGIN + 10, top + 7, { width: innerWidth, lineGap: 2 });
+ doc.x = MARGIN;
+ doc.y = top + height + 14;
+ doc.moveDown(0.5);
+}
+
+function renderBlockquote(doc: PDFKitDoc, token: Tokens.Blockquote): void {
+ const top = doc.y;
+ doc.x = MARGIN + 16;
+ for (const child of token.tokens) renderBlock(doc, child);
+ const bottom = doc.y;
+ doc
+ .save()
+ .lineWidth(2)
+ .strokeColor(ACCENT)
+ .moveTo(MARGIN + 4, top)
+ .lineTo(MARGIN + 4, bottom)
+ .stroke()
+ .restore();
+ doc.x = MARGIN;
+ doc.moveDown(0.3);
+}
+
+function renderTable(doc: PDFKitDoc, token: Tokens.Table): void {
+ const cols = token.header.length;
+ if (cols === 0) return;
+ const colWidth = CONTENT_WIDTH / cols;
+ const cellText = (cell: Tokens.TableCell): string => {
+ const segs: Segment[] = [];
+ flattenInline(cell.tokens, BASE_STYLE, segs);
+ return segs.map((s) => s.text).join("");
+ };
+ const drawRow = (cells: Tokens.TableCell[], header: boolean): void => {
+ const font = header ? FONT.bold : FONT.regular;
+ const color = header ? INK_500 : INK_700;
+ doc.font(font).fontSize(header ? 9 : 10);
+ const heights = cells.map((c) =>
+ doc.heightOfString(cellText(c), { width: colWidth - 12 }),
+ );
+ const rowHeight = Math.max(...heights, 14) + 8;
+ breakIfTight(doc, rowHeight);
+ const top = doc.y;
+ cells.forEach((c, i) => {
+ doc
+ .font(font)
+ .fontSize(header ? 9 : 10)
+ .fillColor(color)
+ .text(cellText(c), MARGIN + i * colWidth, top + 4, { width: colWidth - 12 });
+ });
+ doc.y = top + rowHeight;
+ doc
+ .save()
+ .lineWidth(0.7)
+ .strokeColor(RULE)
+ .moveTo(MARGIN, doc.y)
+ .lineTo(MARGIN + CONTENT_WIDTH, doc.y)
+ .stroke()
+ .restore();
+ };
+ doc.moveDown(0.3);
+ drawRow(token.header, true);
+ for (const row of token.rows) drawRow(row, false);
+ doc.x = MARGIN;
+ doc.moveDown(0.5);
+}
+
+function renderHr(doc: PDFKitDoc): void {
+ doc.moveDown(0.4);
+ doc
+ .save()
+ .lineWidth(0.7)
+ .strokeColor(RULE)
+ .moveTo(MARGIN, doc.y)
+ .lineTo(MARGIN + CONTENT_WIDTH, doc.y)
+ .stroke()
+ .restore();
+ doc.moveDown(0.5);
+}
+
+function renderBlock(doc: PDFKitDoc, token: Token): void {
+ switch (token.type) {
+ case "heading":
+ renderHeading(doc, token as Tokens.Heading);
+ break;
+ case "paragraph":
+ renderParagraph(doc, token as Tokens.Paragraph);
+ break;
+ case "list":
+ renderList(doc, token as Tokens.List);
+ break;
+ case "code":
+ renderCode(doc, token as Tokens.Code);
+ break;
+ case "blockquote":
+ renderBlockquote(doc, token as Tokens.Blockquote);
+ break;
+ case "table":
+ renderTable(doc, token as Tokens.Table);
+ break;
+ case "hr":
+ renderHr(doc);
+ break;
+ case "space":
+ doc.moveDown(0.4);
+ break;
+ case "html":
+ break; // raw HTML is dropped — we render text, not markup
+ default: {
+ // Anything else with text (e.g. a bare text block) still gets rendered.
+ const t = token as { text?: string };
+ if (t.text && t.text.trim()) {
+ doc.font(FONT.regular).fontSize(BODY_SIZE).fillColor(INK_700).text(decodeEntities(t.text), {
+ width: CONTENT_WIDTH,
+ lineGap: LINE_GAP,
+ paragraphGap: 8,
+ });
+ }
+ }
+ }
+}
+
+/**
+ * Render a Markdown string to PDF bytes. The returned buffer is a normal,
+ * text-bearing PDF that `extractPdf` reads exactly like any other upload.
+ */
+export async function markdownToPdf(markdown: string): Promise {
+ if (Buffer.byteLength(markdown, "utf-8") > MAX_MARKDOWN_BYTES) {
+ throw new MarkdownTooLargeError();
+ }
+ if (!markdown.trim()) {
+ throw new MarkdownEmptyError();
+ }
+
+ const tokens = marked.lexer(markdown, { gfm: true });
+
+ const doc = new PDFDocument({
+ size: "A4",
+ margins: { top: MARGIN, bottom: MARGIN, left: MARGIN, right: MARGIN },
+ info: { Producer: "Get It. Markdown Importer" },
+ bufferPages: true,
+ pdfVersion: "1.7",
+ lang: "en-US",
+ });
+
+ const chunks: Buffer[] = [];
+ doc.on("data", (c: Buffer) => chunks.push(c));
+ const done = new Promise((resolve, reject) => {
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
+ doc.on("error", reject);
+ });
+
+ doc.x = MARGIN;
+ for (const token of tokens) renderBlock(doc, token);
+
+ doc.end();
+ return done;
+}
diff --git a/package.json b/package.json
index 3f0c9a5..7534123 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"framer-motion": "^12.38.0",
"katex": "^0.16.45",
"lucide-react": "^1.14.0",
+ "marked": "^14.1.4",
"next": "16.2.6",
"pdfjs-dist": "^5.7.284",
"pdfkit": "^0.18.0",
@@ -64,7 +65,6 @@
"electron-builder": "^25.1.8",
"eslint": "^9",
"eslint-config-next": "16.2.6",
- "marked": "^14.1.4",
"pdf-lib": "^1.17.1",
"playwright": "^1.59.1",
"tailwindcss": "^4",
From 9d075b2e215a17f3f41e7c0bc37072acf4448e40 Mon Sep 17 00:00:00 2001
From: wanikua
Date: Wed, 17 Jun 2026 11:30:04 +0800
Subject: [PATCH 2/4] feat: render CJK Markdown with a system font
When the markdown contains CJK / kana / hangul / fullwidth characters, probe
for a system CJK font (PingFang / Hiragino on macOS, Microsoft YaHei / SimSun
on Windows, Noto Sans CJK / WenQuanYi on Linux) and render the whole document
with it. Those families carry Latin glyphs too, so mixed English/Chinese reads
correctly; pdfkit subsets the font, so only the glyphs actually used are
embedded. When no CJK font is present we fall back to the Latin fonts.
Verified round-trip: a Chinese study note renders, embeds, and is re-extracted
by pdf.js as correct Chinese text, clearing the text-coverage gate.
---
lib/md-to-pdf.ts | 197 ++++++++++++++++++++++++++++++++++++++---------
1 file changed, 159 insertions(+), 38 deletions(-)
diff --git a/lib/md-to-pdf.ts b/lib/md-to-pdf.ts
index 85562b6..eb30812 100644
--- a/lib/md-to-pdf.ts
+++ b/lib/md-to-pdf.ts
@@ -11,10 +11,18 @@
* We tokenize with `marked` (already a project dependency) and lay the
* tokens out with `pdfkit` (the same library `scripts/generate-sample-pdfs.ts`
* uses to mint the bundled sample documents), so this adds no new runtime
- * dependency. The standard PDF fonts cover Latin scripts; non-Latin scripts
- * (CJK, etc.) would need an embedded font and are a deliberate follow-up.
+ * dependency.
+ *
+ * Scripts: the standard PDF fonts cover Latin. When the document contains
+ * CJK (or kana / hangul / fullwidth) characters we register a system CJK
+ * font and render the whole document with it — those fonts carry Latin
+ * glyphs too, so mixed English/Chinese reads correctly. If no CJK font is
+ * found on the host we fall back to the Latin fonts and Latin text still
+ * renders; the text-coverage gate downstream will reject a doc that came out
+ * blank, which is the right outcome on a system with no CJK font at all.
*/
+import fs from "node:fs";
import PDFDocument from "pdfkit";
import { marked, type Token, type Tokens } from "marked";
@@ -56,13 +64,27 @@ const ACCENT = "#4f5ae0";
const RULE = "#cbd5e1";
const CODE_BG = "#f3f2ef";
-const FONT = {
+/** The four weights + monospace a document is drawn with. Swapped wholesale
+ * for a registered CJK family when the source contains CJK characters. */
+type Fonts = {
+ regular: string;
+ bold: string;
+ italic: string;
+ boldItalic: string;
+ mono: string;
+};
+
+const LATIN_FONTS: Fonts = {
regular: "Helvetica",
bold: "Helvetica-Bold",
italic: "Helvetica-Oblique",
boldItalic: "Helvetica-BoldOblique",
mono: "Courier",
-} as const;
+};
+
+/** Matches CJK ideographs, kana, hangul, and CJK/fullwidth punctuation. */
+const CJK_RE =
+ /[ -ヿ㐀-䶿一-鿿豈--가-]/;
/** Point size per heading depth (h1…h6). */
const HEADING_SIZE = [22, 17, 14, 12.5, 11.5, 11];
@@ -72,6 +94,9 @@ const LINE_GAP = 2.5;
type PDFKitDoc = InstanceType;
+/** Render context threaded through every block renderer. */
+type Ctx = { doc: PDFKitDoc; fonts: Fonts };
+
/** Inline run after emphasis/link nesting has been flattened to leaves. */
type Segment = {
text: string;
@@ -85,6 +110,94 @@ type Style = { bold: boolean; italic: boolean };
const BASE_STYLE: Style = { bold: false, italic: false };
+// ── CJK font resolution ─────────────────────────────────────────────────
+
+type CjkFace = { path: string; postscript?: string };
+type CjkPair = { regular: CjkFace; bold: CjkFace };
+
+/** Per-platform candidate CJK fonts, in preference order. `postscript` names
+ * a face inside a `.ttc` collection (omitted for single-face `.ttf`/`.otf`). */
+function cjkCandidates(): CjkPair[] {
+ switch (process.platform) {
+ case "darwin":
+ return [
+ {
+ regular: { path: "/System/Library/Fonts/PingFang.ttc", postscript: "PingFangSC-Regular" },
+ bold: { path: "/System/Library/Fonts/PingFang.ttc", postscript: "PingFangSC-Semibold" },
+ },
+ {
+ regular: { path: "/System/Library/Fonts/Hiragino Sans GB.ttc", postscript: "HiraginoSansGB-W3" },
+ bold: { path: "/System/Library/Fonts/Hiragino Sans GB.ttc", postscript: "HiraginoSansGB-W6" },
+ },
+ {
+ regular: { path: "/System/Library/Fonts/Supplemental/Arial Unicode.ttf" },
+ bold: { path: "/System/Library/Fonts/Supplemental/Arial Unicode.ttf" },
+ },
+ ];
+ case "win32":
+ return [
+ {
+ regular: { path: "C:\\Windows\\Fonts\\msyh.ttc", postscript: "MicrosoftYaHei" },
+ bold: { path: "C:\\Windows\\Fonts\\msyhbd.ttc", postscript: "MicrosoftYaHei-Bold" },
+ },
+ {
+ regular: { path: "C:\\Windows\\Fonts\\simsun.ttc", postscript: "SimSun" },
+ bold: { path: "C:\\Windows\\Fonts\\simsun.ttc", postscript: "SimSun" },
+ },
+ {
+ regular: { path: "C:\\Windows\\Fonts\\malgun.ttf" },
+ bold: { path: "C:\\Windows\\Fonts\\malgunbd.ttf" },
+ },
+ ];
+ default:
+ return [
+ {
+ regular: { path: "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", postscript: "NotoSansCJKsc-Regular" },
+ bold: { path: "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", postscript: "NotoSansCJKsc-Bold" },
+ },
+ {
+ regular: { path: "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf", postscript: "NotoSansCJKsc-Regular" },
+ bold: { path: "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Bold.otf", postscript: "NotoSansCJKsc-Bold" },
+ },
+ {
+ regular: { path: "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc" },
+ bold: { path: "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc" },
+ },
+ ];
+ }
+}
+
+/** First candidate whose regular face exists on disk, or null. */
+function resolveCjkFont(): CjkPair | null {
+ for (const c of cjkCandidates()) {
+ if (fs.existsSync(c.regular.path)) {
+ return { regular: c.regular, bold: fs.existsSync(c.bold.path) ? c.bold : c.regular };
+ }
+ }
+ return null;
+}
+
+/**
+ * If the markdown needs CJK and a system font is available, register it on
+ * the document and return a CJK font map; otherwise return the Latin map.
+ */
+function setUpFonts(doc: PDFKitDoc, markdown: string): Fonts {
+ if (!CJK_RE.test(markdown)) return LATIN_FONTS;
+ const face = resolveCjkFont();
+ if (!face) return LATIN_FONTS;
+ try {
+ doc.registerFont("cjk", face.regular.path, face.regular.postscript);
+ doc.registerFont("cjk-bold", face.bold.path, face.bold.postscript);
+ } catch {
+ return LATIN_FONTS;
+ }
+ // CJK has no italic; reuse the upright faces. Code uses the CJK font too so
+ // CJK inside code blocks still renders (at the cost of true monospacing).
+ return { regular: "cjk", bold: "cjk-bold", italic: "cjk", boldItalic: "cjk-bold", mono: "cjk" };
+}
+
+// ── Inline flattening ───────────────────────────────────────────────────
+
/** Decode the handful of HTML entities `marked` leaves encoded in token text. */
function decodeEntities(s: string): string {
return s
@@ -155,12 +268,12 @@ function flattenInline(tokens: Token[] | undefined, style: Style, out: Segment[]
}
}
-function fontFor(seg: Segment): string {
- if (seg.mono) return FONT.mono;
- if (seg.bold && seg.italic) return FONT.boldItalic;
- if (seg.bold) return FONT.bold;
- if (seg.italic) return FONT.italic;
- return FONT.regular;
+function fontFor(seg: Segment, fonts: Fonts): string {
+ if (seg.mono) return fonts.mono;
+ if (seg.bold && seg.italic) return fonts.boldItalic;
+ if (seg.bold) return fonts.bold;
+ if (seg.italic) return fonts.italic;
+ return fonts.regular;
}
type EmitOpts = {
@@ -172,7 +285,8 @@ type EmitOpts = {
};
/** Emit a run of flattened segments as a single wrapped paragraph. */
-function emitSegments(doc: PDFKitDoc, segs: Segment[], opts: EmitOpts): void {
+function emitSegments(ctx: Ctx, segs: Segment[], opts: EmitOpts): void {
+ const { doc, fonts } = ctx;
const width = opts.width ?? CONTENT_WIDTH - (opts.indent ?? 0);
if (segs.length === 0) {
doc.text(" ", { width });
@@ -181,7 +295,7 @@ function emitSegments(doc: PDFKitDoc, segs: Segment[], opts: EmitOpts): void {
const last = segs.length - 1;
segs.forEach((seg, i) => {
doc
- .font(fontFor(seg))
+ .font(fontFor(seg, fonts))
.fontSize(opts.size)
.fillColor(seg.link ? ACCENT : opts.color);
const textOpts: PDFKit.Mixins.TextOptions = {
@@ -205,7 +319,8 @@ function breakIfTight(doc: PDFKitDoc, needed: number): void {
// ── Block renderers ─────────────────────────────────────────────────────
-function renderHeading(doc: PDFKitDoc, token: Tokens.Heading): void {
+function renderHeading(ctx: Ctx, token: Tokens.Heading): void {
+ const { doc } = ctx;
const size = HEADING_SIZE[Math.min(token.depth, HEADING_SIZE.length) - 1];
breakIfTight(doc, size * 2.4);
doc.moveDown(token.depth <= 2 ? 0.6 : 0.4);
@@ -213,17 +328,18 @@ function renderHeading(doc: PDFKitDoc, token: Tokens.Heading): void {
flattenInline(token.tokens, BASE_STYLE, segs);
// Headings render in a single weight regardless of inline emphasis.
for (const s of segs) s.bold = true;
- emitSegments(doc, segs, { size, color: INK_900, paragraphGap: 4 });
+ emitSegments(ctx, segs, { size, color: INK_900, paragraphGap: 4 });
doc.moveDown(0.25);
}
-function renderParagraph(doc: PDFKitDoc, token: Tokens.Paragraph): void {
+function renderParagraph(ctx: Ctx, token: Tokens.Paragraph): void {
const segs: Segment[] = [];
flattenInline(token.tokens, BASE_STYLE, segs);
- emitSegments(doc, segs, { size: BODY_SIZE, color: INK_700, paragraphGap: 8 });
+ emitSegments(ctx, segs, { size: BODY_SIZE, color: INK_700, paragraphGap: 8 });
}
-function renderList(doc: PDFKitDoc, token: Tokens.List, depth = 0): void {
+function renderList(ctx: Ctx, token: Tokens.List, depth = 0): void {
+ const { doc, fonts } = ctx;
const indent = 18 + depth * 16;
let index = typeof token.start === "number" ? token.start : 1;
for (const item of token.items) {
@@ -231,7 +347,7 @@ function renderList(doc: PDFKitDoc, token: Tokens.List, depth = 0): void {
breakIfTight(doc, BODY_SIZE * 2);
const markerX = MARGIN + indent - 14;
const y = doc.y;
- doc.font(FONT.regular).fontSize(BODY_SIZE).fillColor(INK_500).text(marker, markerX, y, {
+ doc.font(fonts.regular).fontSize(BODY_SIZE).fillColor(INK_500).text(marker, markerX, y, {
width: 14,
lineGap: LINE_GAP,
});
@@ -246,7 +362,7 @@ function renderList(doc: PDFKitDoc, token: Tokens.List, depth = 0): void {
else if (child.type === "text") flattenInline((child as Tokens.Text).tokens ?? [child as Token], BASE_STYLE, inline);
else flattenInline([child], BASE_STYLE, inline);
}
- emitSegments(doc, inline, {
+ emitSegments(ctx, inline, {
size: BODY_SIZE,
color: INK_700,
indent,
@@ -254,15 +370,16 @@ function renderList(doc: PDFKitDoc, token: Tokens.List, depth = 0): void {
paragraphGap: 3,
});
doc.x = MARGIN;
- for (const sub of nested) renderList(doc, sub, depth + 1);
+ for (const sub of nested) renderList(ctx, sub, depth + 1);
index++;
}
doc.moveDown(0.3);
}
-function renderCode(doc: PDFKitDoc, token: Tokens.Code): void {
+function renderCode(ctx: Ctx, token: Tokens.Code): void {
+ const { doc, fonts } = ctx;
const code = token.text.replace(/\n+$/, "");
- doc.font(FONT.mono).fontSize(CODE_SIZE);
+ doc.font(fonts.mono).fontSize(CODE_SIZE);
const innerWidth = CONTENT_WIDTH - 20;
const height = doc.heightOfString(code, { width: innerWidth, lineGap: 2 });
breakIfTight(doc, height + 16);
@@ -273,7 +390,7 @@ function renderCode(doc: PDFKitDoc, token: Tokens.Code): void {
.fill(CODE_BG)
.restore();
doc
- .font(FONT.mono)
+ .font(fonts.mono)
.fontSize(CODE_SIZE)
.fillColor(INK_700)
.text(code, MARGIN + 10, top + 7, { width: innerWidth, lineGap: 2 });
@@ -282,10 +399,11 @@ function renderCode(doc: PDFKitDoc, token: Tokens.Code): void {
doc.moveDown(0.5);
}
-function renderBlockquote(doc: PDFKitDoc, token: Tokens.Blockquote): void {
+function renderBlockquote(ctx: Ctx, token: Tokens.Blockquote): void {
+ const { doc } = ctx;
const top = doc.y;
doc.x = MARGIN + 16;
- for (const child of token.tokens) renderBlock(doc, child);
+ for (const child of token.tokens) renderBlock(ctx, child);
const bottom = doc.y;
doc
.save()
@@ -299,7 +417,8 @@ function renderBlockquote(doc: PDFKitDoc, token: Tokens.Blockquote): void {
doc.moveDown(0.3);
}
-function renderTable(doc: PDFKitDoc, token: Tokens.Table): void {
+function renderTable(ctx: Ctx, token: Tokens.Table): void {
+ const { doc, fonts } = ctx;
const cols = token.header.length;
if (cols === 0) return;
const colWidth = CONTENT_WIDTH / cols;
@@ -309,7 +428,7 @@ function renderTable(doc: PDFKitDoc, token: Tokens.Table): void {
return segs.map((s) => s.text).join("");
};
const drawRow = (cells: Tokens.TableCell[], header: boolean): void => {
- const font = header ? FONT.bold : FONT.regular;
+ const font = header ? fonts.bold : fonts.regular;
const color = header ? INK_500 : INK_700;
doc.font(font).fontSize(header ? 9 : 10);
const heights = cells.map((c) =>
@@ -355,31 +474,31 @@ function renderHr(doc: PDFKitDoc): void {
doc.moveDown(0.5);
}
-function renderBlock(doc: PDFKitDoc, token: Token): void {
+function renderBlock(ctx: Ctx, token: Token): void {
switch (token.type) {
case "heading":
- renderHeading(doc, token as Tokens.Heading);
+ renderHeading(ctx, token as Tokens.Heading);
break;
case "paragraph":
- renderParagraph(doc, token as Tokens.Paragraph);
+ renderParagraph(ctx, token as Tokens.Paragraph);
break;
case "list":
- renderList(doc, token as Tokens.List);
+ renderList(ctx, token as Tokens.List);
break;
case "code":
- renderCode(doc, token as Tokens.Code);
+ renderCode(ctx, token as Tokens.Code);
break;
case "blockquote":
- renderBlockquote(doc, token as Tokens.Blockquote);
+ renderBlockquote(ctx, token as Tokens.Blockquote);
break;
case "table":
- renderTable(doc, token as Tokens.Table);
+ renderTable(ctx, token as Tokens.Table);
break;
case "hr":
- renderHr(doc);
+ renderHr(ctx.doc);
break;
case "space":
- doc.moveDown(0.4);
+ ctx.doc.moveDown(0.4);
break;
case "html":
break; // raw HTML is dropped — we render text, not markup
@@ -387,7 +506,7 @@ function renderBlock(doc: PDFKitDoc, token: Token): void {
// Anything else with text (e.g. a bare text block) still gets rendered.
const t = token as { text?: string };
if (t.text && t.text.trim()) {
- doc.font(FONT.regular).fontSize(BODY_SIZE).fillColor(INK_700).text(decodeEntities(t.text), {
+ ctx.doc.font(ctx.fonts.regular).fontSize(BODY_SIZE).fillColor(INK_700).text(decodeEntities(t.text), {
width: CONTENT_WIDTH,
lineGap: LINE_GAP,
paragraphGap: 8,
@@ -427,8 +546,10 @@ export async function markdownToPdf(markdown: string): Promise {
doc.on("error", reject);
});
+ const ctx: Ctx = { doc, fonts: setUpFonts(doc, markdown) };
+
doc.x = MARGIN;
- for (const token of tokens) renderBlock(doc, token);
+ for (const token of tokens) renderBlock(ctx, token);
doc.end();
return done;
From 2ffc23234187cf35c2b52330825746d466b435c9 Mon Sep 17 00:00:00 2001
From: wanikua
Date: Wed, 17 Jun 2026 12:01:58 +0800
Subject: [PATCH 3/4] test: behavior tests for the Markdown importer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add scripts/test-md-import.ts (run via `npm run test:md`), matching the
existing tsx + check() harness. Tests the public interface only — markdownToPdf
and the bytes it returns, round-tripped through the same extractPdf the upload
pipeline uses — so they survive a rewrite of the layout internals:
- renders a valid PDF
- round-trip fidelity: heading, bold, inline code, list, blockquote, code
block, table cell, and link text all survive into the extracted text, and a
substantive doc clears the text-coverage gate
- empty / whitespace-only input is rejected with MarkdownEmptyError
- oversized input is rejected with MarkdownTooLargeError
- CJK never throws and yields a valid PDF; the Chinese round-trip is asserted
when a CJK system font is present and skipped otherwise (bare CI box)
---
package.json | 1 +
scripts/test-md-import.ts | 172 ++++++++++++++++++++++++++++++++++++++
2 files changed, 173 insertions(+)
create mode 100644 scripts/test-md-import.ts
diff --git a/package.json b/package.json
index 7534123..9684fa5 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"lint": "eslint",
"generate-pdfs": "tsx scripts/generate-sample-pdfs.ts",
"test:errors": "tsx scripts/test-error-handling.ts",
+ "test:md": "tsx scripts/test-md-import.ts",
"smoke": "node scripts/smoke-test.mjs",
"smoke-all": "node scripts/full-test.mjs",
"build-writeup": "node scripts/build-pdf.mjs",
diff --git a/scripts/test-md-import.ts b/scripts/test-md-import.ts
new file mode 100644
index 0000000..5435819
--- /dev/null
+++ b/scripts/test-md-import.ts
@@ -0,0 +1,172 @@
+/**
+ * Behavior tests for the Markdown importer (lib/md-to-pdf.ts).
+ *
+ * Run: npx tsx scripts/test-md-import.ts
+ *
+ * These exercise the PUBLIC interface only — `markdownToPdf` and the bytes it
+ * returns — and verify the document round-trips through the same `extractPdf`
+ * the upload pipeline uses. They make no assumptions about how the renderer
+ * lays anything out, so they survive a rewrite of the layout internals: the
+ * contract is "a .md file becomes a text-bearing PDF whose text the agents can
+ * read", and that is what we assert.
+ */
+
+import {
+ markdownToPdf,
+ MarkdownEmptyError,
+ MarkdownTooLargeError,
+ MAX_MARKDOWN_BYTES,
+} from "../lib/md-to-pdf";
+import { extractPdf, assessPdfQuality } from "../lib/pdf-extract";
+
+let failures = 0;
+function check(name: string, cond: boolean, detail?: string) {
+ if (!cond) failures++;
+ console.log(`${cond ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
+}
+function skip(name: string, detail?: string) {
+ console.log(`SKIP ${name}${detail ? ` — ${detail}` : ""}`);
+}
+
+/** Collapse whitespace so substring checks ignore the renderer's spacing. */
+const flat = (s: string) => s.replace(/\s+/g, " ").trim();
+
+/** Render markdown and pull the text back out the way the pipeline does. */
+async function roundTrip(md: string) {
+ const pdf = await markdownToPdf(md);
+ const u8 = new Uint8Array(pdf.byteLength);
+ u8.set(pdf);
+ const extracted = await extractPdf(u8);
+ const text = flat(extracted.pages.map((p) => p.text).join("\n"));
+ return { pdf, extracted, text };
+}
+
+async function main() {
+ // 1) Tracer bullet: a basic document renders to a real PDF.
+ {
+ const pdf = await markdownToPdf("# Title\n\nA paragraph of body text.");
+ check(
+ "renders a valid PDF (starts with %PDF-)",
+ pdf.length > 0 && pdf.subarray(0, 5).toString("ascii") === "%PDF-",
+ `${pdf.length} bytes, header ${pdf.subarray(0, 8).toString("ascii")}`,
+ );
+ }
+
+ // 2) Round-trip fidelity: every block type's text survives into the PDF, so the
+ // detector / chat / flashcards see the real content, not a blank page.
+ {
+ const md = [
+ "# Photosynthesis",
+ "",
+ "Plants convert **light** energy into _chemical_ energy stored as `glucose`.",
+ "",
+ "Photosynthesis is the process by which green plants, algae, and some bacteria",
+ "capture energy from sunlight and use it to synthesise organic compounds from",
+ "carbon dioxide and water. It is the foundation of almost every food chain on",
+ "the planet and the original source of the oxygen in the atmosphere. The overall",
+ "reaction splits water, releases oxygen as a by-product, and fixes carbon into",
+ "sugars that store the captured energy in their chemical bonds for later use.",
+ "",
+ "## Inputs",
+ "",
+ "1. Carbon dioxide from the air.",
+ "2. Water drawn up through the roots.",
+ "",
+ "> The light reactions occur in the thylakoid membrane.",
+ "",
+ "```",
+ "6 CO2 + 6 H2O -> C6H12O6 + 6 O2",
+ "```",
+ "",
+ "| Stage | Location |",
+ "|-------|----------|",
+ "| Light reactions | Thylakoid |",
+ "",
+ "See [the chapter](https://example.com/photosynthesis) for the full pathway.",
+ ].join("\n");
+ const { text, extracted } = await roundTrip(md);
+ check("round-trip: heading text present", text.includes("Photosynthesis"));
+ check("round-trip: bold inline text present", text.includes("light"));
+ check("round-trip: inline code text present", text.includes("glucose"));
+ check("round-trip: list item text present", text.includes("Carbon dioxide from the air"));
+ check("round-trip: blockquote text present", text.includes("thylakoid membrane"));
+ check("round-trip: code block text present", text.includes("C6H12O6"));
+ check("round-trip: table cell text present", text.includes("Thylakoid"));
+ check("round-trip: link label text present", text.includes("the chapter"));
+ check(
+ "round-trip: a substantive doc clears the text-coverage gate",
+ assessPdfQuality(extracted).ok,
+ JSON.stringify(assessPdfQuality(extracted).stats),
+ );
+}
+
+// 3) Empty / whitespace-only markdown is rejected with a typed error.
+{
+ for (const [label, input] of [
+ ["empty string", ""],
+ ["whitespace only", " \n\t \n"],
+ ] as const) {
+ let thrown: unknown;
+ try {
+ await markdownToPdf(input);
+ } catch (e) {
+ thrown = e;
+ }
+ check(`rejects ${label} with MarkdownEmptyError`, thrown instanceof MarkdownEmptyError);
+ }
+}
+
+// 4) Oversized markdown is rejected before rendering.
+{
+ const huge = "a".repeat(MAX_MARKDOWN_BYTES + 1);
+ let thrown: unknown;
+ try {
+ await markdownToPdf(huge);
+ } catch (e) {
+ thrown = e;
+ }
+ check("rejects >MAX_MARKDOWN_BYTES with MarkdownTooLargeError", thrown instanceof MarkdownTooLargeError);
+}
+
+// 5) CJK is always safe to render; when the host has a CJK font, the Chinese
+// text round-trips. On a host with no CJK font (e.g. a bare CI box) the
+// renderer falls back to Latin fonts — still a valid PDF — so we only assert
+// the strong extraction when a CJK font is actually present.
+{
+ const zh = "光合作用把光能转化为化学能";
+ const md =
+ `# 光合作用\n\n${zh},并以葡萄糖的形式储存在植物体内,这是地球上几乎所有生命能量的最终来源。` +
+ "光反应发生在类囊体膜上,暗反应也就是卡尔文循环发生在叶绿体基质中,二者协同把二氧化碳固定为有机物。";
+ let pdf: Buffer | undefined;
+ let threw = false;
+ try {
+ const r = await roundTrip(md);
+ pdf = r.pdf;
+ if (r.text.includes(zh)) {
+ check("CJK: Chinese text round-trips when a CJK font is present", true);
+ } else {
+ skip("CJK: Chinese text round-trips", "no CJK system font on this host — fell back to Latin");
+ }
+ } catch {
+ threw = true;
+ }
+ check(
+ "CJK: rendering never throws and yields a valid PDF",
+ !threw && !!pdf && pdf.subarray(0, 5).toString("ascii") === "%PDF-",
+ );
+ }
+}
+
+main()
+ .then(() => {
+ console.log("");
+ if (failures > 0) {
+ console.error(`✗ ${failures} check(s) failed`);
+ process.exit(1);
+ }
+ console.log("✓ all checks passed");
+ })
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ });
From b7989ea259f8490663d74570ded91297f4f51447 Mon Sep 17 00:00:00 2001
From: wanikua
Date: Wed, 17 Jun 2026 12:08:37 +0800
Subject: [PATCH 4/4] feat: strip .md/.markdown from document titles too
Library, viewer, and the KG job titler stripped only `.pdf` from a filename
when no curated title matched, so an imported `notes.md` displayed as
`notes.md`. Extend the suffix strip to the markdown extensions we accept,
mirroring MARKDOWN_EXT in the upload route.
---
app/library/library-client.tsx | 2 +-
app/viewer/[docId]/viewer-client.tsx | 2 +-
components/UploadCard.tsx | 2 +-
lib/jobs.ts | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/app/library/library-client.tsx b/app/library/library-client.tsx
index e736b4e..5c5b01a 100644
--- a/app/library/library-client.tsx
+++ b/app/library/library-client.tsx
@@ -48,7 +48,7 @@ const FILENAME_TO_TITLE: Record = {
};
function titleOf(filename: string): string {
- return FILENAME_TO_TITLE[filename] ?? filename.replace(/\.pdf$/i, "");
+ return FILENAME_TO_TITLE[filename] ?? filename.replace(/\.(pdf|md|markdown|mdown|mkd|mdwn)$/i, "");
}
function humaniseAgo(ts: number): string {
diff --git a/app/viewer/[docId]/viewer-client.tsx b/app/viewer/[docId]/viewer-client.tsx
index 8ca3166..a9b257b 100644
--- a/app/viewer/[docId]/viewer-client.tsx
+++ b/app/viewer/[docId]/viewer-client.tsx
@@ -286,7 +286,7 @@ export default function ViewerClient({ docId }: { docId: string }) {
const docTitle = useMemo(
() =>
- meta && (FILENAME_TO_TITLE[meta.filename] || meta.filename.replace(/\.pdf$/i, "")),
+ meta && (FILENAME_TO_TITLE[meta.filename] || meta.filename.replace(/\.(pdf|md|markdown|mdown|mkd|mdwn)$/i, "")),
[meta],
);
diff --git a/components/UploadCard.tsx b/components/UploadCard.tsx
index 3940c84..1a86d8d 100644
--- a/components/UploadCard.tsx
+++ b/components/UploadCard.tsx
@@ -305,7 +305,7 @@ export default function UploadCard() {