From 2dbf74daba22ceb211e80fb1627a33ac13234e95 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:47:32 +0530 Subject: [PATCH 1/3] feat(kit): a PDF can have its own paper, text size, typeface, margins, accent colour, numbering and date renderDocument takes a style. The choices are written into the preamble of main.tex after the house style, so lucid.sty stays one fixed file, and a document with no style is exactly what it was. The typefaces are Latin Modern (the house style) and three TeX Gyre faces (Palatino, Times, Helvetica) that the PDF engine brings along. Figures are fitted to about the size of the text area the style gives. Unit tests cover the LaTeX each choice writes, and the Tectonic tests compile every typeface and all choices together and check the embedded fonts, the A4 page size, the missing numbers and the missing date. Co-Authored-By: Claude Sonnet 5.5 --- packages/kit/src/latex/blocks.ts | 7 +- packages/kit/src/latex/compile.test.ts | 81 ++++++++++++++- packages/kit/src/latex/document.ts | 20 +++- packages/kit/src/latex/index.ts | 16 +++ packages/kit/src/latex/style.test.ts | 131 +++++++++++++++++++++++++ packages/kit/src/latex/style.ts | 129 ++++++++++++++++++++++++ 6 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 packages/kit/src/latex/style.test.ts create mode 100644 packages/kit/src/latex/style.ts diff --git a/packages/kit/src/latex/blocks.ts b/packages/kit/src/latex/blocks.ts index 682188a..b4b3ce3 100644 --- a/packages/kit/src/latex/blocks.ts +++ b/packages/kit/src/latex/blocks.ts @@ -31,6 +31,9 @@ export interface LatexContext { figureWidths?: Readonly>; /** The natural height of each figure in points, by block id: with it a small figure is not enlarged into a tall one. */ figureHeights?: Readonly>; + /** About how big the text area is in points (default: 345 by 592, the house style on Letter paper). */ + textWidth?: number; + textHeight?: number; } /** How far the shallowest heading in the blocks is below level 1 (0 when there is a level 1 or no heading). */ @@ -223,7 +226,9 @@ function figure(block: Block, ctx: LatexContext): string { // A big one (wider than the text, about 345 pt, or taller than 40% of the 592 pt text height) cannot always sit exactly here: // it may float to the top of the next page, and the text that follows fills the page it leaves, instead of a page that // holds only a heading and a gap. - const big = (natural !== undefined && natural > 340) || (naturalHeight ?? 0) > 237; + const big = + (natural !== undefined && natural > (ctx.textWidth ?? 345) - 5) || + (naturalHeight ?? 0) > 0.4 * (ctx.textHeight ?? 592); const place = big ? "htbp" : "H"; return [ `\\begin{figure}[${place}]\\centering`, diff --git a/packages/kit/src/latex/compile.test.ts b/packages/kit/src/latex/compile.test.ts index a2156aa..e35a148 100644 --- a/packages/kit/src/latex/compile.test.ts +++ b/packages/kit/src/latex/compile.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { registry } from "../defaultRegistry"; import { FORMATS, skeleton, type Format } from "../formats"; -import { LUCID_STY, renderDocument } from "./index"; +import { FONTS, LUCID_STY, renderDocument, type ResolvedPdfStyle } from "./index"; // Integration: every block type's fixtures, with figures, compile to a real PDF with Tectonic (skipped when the tools are // not installed). This is what proves the LaTeX the model's content turns into is valid. @@ -27,6 +27,7 @@ function compile( info?: Record; figureWidths?: Record; figureHeights?: Record; + style?: Partial; } = {}, ): { pdf: string; text: string } { const dir = mkdtempSync(join(tmpdir(), "lucid-tex-")); @@ -118,6 +119,84 @@ describe.skipIf(!have)("LaTeX compiles with Tectonic", () => { }, 300_000); }); +const PDFFONTS = "/opt/homebrew/bin/pdffonts"; +const haveFonts = have && existsSync(PDFFONTS); + +describe.skipIf(!haveFonts)("PDF style choices compile with Tectonic", () => { + const fixture = (type: "table") => { + const found = registry.get(type).fixtures[0]; + if (!found) throw new Error(`no ${type} fixture`); + return found; + }; + const blocks = () => { + return [ + registry.validateBlock({ + id: "h1", + type: "heading", + props: { level: 1 }, + content: [{ type: "text", text: "Caching strategies", styles: {} }], + }), + registry.validateBlock({ + id: "p1", + type: "paragraph", + props: {}, + content: [ + { + type: "text", + text: "Hot keys stay in memory so most reads never reach the database.", + styles: {}, + }, + ], + }), + registry.validateBlock(fixture("table")), + ]; + }; + const fontsOf = (pdf: string): string => execFileSync(PDFFONTS, [pdf], { encoding: "utf8" }); + const FONT_FILES = { + "latin-modern": "LMRoman", + palatino: "TeXGyrePagella", + times: "TeXGyreTermes", + helvetica: "TeXGyreHeros", + } as const; + + for (const font of FONTS) { + it(`${font}: the typeface is embedded in the PDF`, () => { + const { pdf } = compile("Fonts", blocks(), "", { style: { font } }); + expect(fontsOf(pdf)).toContain(FONT_FILES[font]); + }, 300_000); + } + + it("paper, text size, margins, accent and numbering all at once", () => { + const { pdf, text } = compile("Everything", blocks(), "", { + style: { + paper: "a4", + fontSize: 12, + font: "palatino", + margins: "narrow", + accent: "teal", + numberedHeadings: false, + showDate: false, + }, + }); + expect(text).toContain("Caching strategies"); + // No section number in front of the heading, and no date. + expect(text).not.toMatch(/1\s+Caching strategies/); + expect(text).not.toMatch( + /(January|February|March|April|May|June|July|August|September|October|November|December) \d/, + ); + const size = execFileSync("/opt/homebrew/bin/pdfinfo", [pdf], { encoding: "utf8" }); + expect(size).toMatch(/Page size:\s+595\.\d+ x 841\.\d+ pts \(A4\)/); + }, 300_000); + + it("the numbers and the date are there by default", () => { + const { text } = compile("Defaults", blocks(), "", { style: {} }); + expect(text).toMatch(/1\s+Caching strategies/); + expect(text).toMatch( + /(January|February|March|April|May|June|July|August|September|October|November|December) \d/, + ); + }, 300_000); +}); + describe.skipIf(!have)("a tall diagram", () => { it("never leaves an empty page behind (a diagram almost as tall as the page cannot share it with a heading)", () => { const words = (n: number) => "Words that fill the first page of the note. ".repeat(n); diff --git a/packages/kit/src/latex/document.ts b/packages/kit/src/latex/document.ts index 9a0f4e3..3014823 100644 --- a/packages/kit/src/latex/document.ts +++ b/packages/kit/src/latex/document.ts @@ -4,6 +4,13 @@ import { escapeLatex } from "./escape"; import { inlineToLatex } from "./inline"; import { inlineToPlain } from "../text"; import { META_LABEL, type Format } from "../formats/types"; +import { + classOptions, + preambleLines, + resolveStyle, + textSize, + type ResolvedPdfStyle, +} from "./style"; export interface DocumentInput { title: string; @@ -19,6 +26,8 @@ export interface DocumentInput { figureWidths?: Readonly> | undefined; /** The natural height in points of each figure that was rendered, by block id. */ figureHeights?: Readonly> | undefined; + /** How the PDF looks (paper, text size, typeface, margins, accent, numbering, date); the house style when absent. */ + style?: Partial | undefined; } export interface RenderedDocument { @@ -75,6 +84,8 @@ export function renderDocument( input: DocumentInput, rendered: Readonly> = {}, ): RenderedDocument { + const style = input.style ? { ...resolveStyle({}, "US"), ...input.style } : undefined; + const size = style ? textSize(style) : undefined; const kept = withoutTitleHeading(input.doc.blocks, input.title); const blocks = input.format ? withoutEmptySections(kept) : kept; const figures = collectFigures(blocks); @@ -84,6 +95,7 @@ export function renderDocument( figures: known, ...(input.figureWidths ? { figureWidths: input.figureWidths } : {}), ...(input.figureHeights ? { figureHeights: input.figureHeights } : {}), + ...(size ? { textWidth: size.width, textHeight: size.height } : {}), headingOffset: headingOffsetOf(blocks), ...(input.format ? { @@ -96,13 +108,15 @@ export function renderDocument( .map((field) => ({ field, value: (input.info?.[field] ?? "").trim() })) .filter((m) => m.value.length > 0) .map((m) => `\\lucidmeta{${META_LABEL[m.field]}}{${escapeLatex(m.value)}}`); + const titleText = `\\underline{${escapeLatex(input.title.trim() || "Untitled")}}`; const tex = [ - "\\documentclass{article}", + style ? `\\documentclass[${classOptions(style)}]{article}` : "\\documentclass{article}", "\\usepackage{lucid}", + ...(style ? preambleLines(style) : []), `\\renewcommand{\\lucidfooterright}{${escapeLatex(input.footerRight ?? "")}}`, - `\\title{\\underline{${escapeLatex(input.title.trim() || "Untitled")}}}`, + `\\title{${style && style.accent !== "none" ? `\\textcolor{lucidaccent}{${titleText}}` : titleText}}`, `\\author{${escapeLatex(input.author ?? "")}}`, - "\\date{\\today}", + style && !style.showDate ? "\\date{}" : "\\date{\\today}", "\\begin{document}", "\\maketitle", ...meta, diff --git a/packages/kit/src/latex/index.ts b/packages/kit/src/latex/index.ts index 9032d91..3f43bfd 100644 --- a/packages/kit/src/latex/index.ts +++ b/packages/kit/src/latex/index.ts @@ -8,3 +8,19 @@ export { export { escapeLatex } from "./escape"; export { inlineToLatex } from "./inline"; export { LUCID_STY } from "./lucidSty"; +export { + ACCENTS, + ACCENT_COLORS, + DEFAULT_PDF_STYLE, + FONTS, + FONT_NAMES, + FONT_SIZES, + MARGINS, + PAPERS, + PdfStyleSchema, + paperForCountry, + resolveStyle, + textSize, + type PdfStyle, + type ResolvedPdfStyle, +} from "./style"; diff --git a/packages/kit/src/latex/style.test.ts b/packages/kit/src/latex/style.test.ts new file mode 100644 index 0000000..8d9b11f --- /dev/null +++ b/packages/kit/src/latex/style.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { registry } from "../defaultRegistry"; +import { renderDocument } from "./document"; +import { + ACCENTS, + DEFAULT_PDF_STYLE, + FONTS, + PdfStyleSchema, + paperForCountry, + resolveStyle, + textSize, +} from "./style"; + +const doc = { + blocks: [ + registry.validateBlock({ + id: "h", + type: "heading", + props: { level: 1 }, + content: [{ type: "text", text: "Caching", styles: {} }], + }), + ], +}; +const tex = (style?: Parameters[0]["style"]): string => + renderDocument({ title: "Notes", doc, style }).tex; + +describe("the PDF style choices", () => { + it("default to the house style", () => { + expect(DEFAULT_PDF_STYLE).toEqual({ + paper: "auto", + fontSize: 10, + font: "latin-modern", + margins: "normal", + accent: "none", + numberedHeadings: true, + showDate: true, + }); + }); + + it("refuse values that are not offered, and unknown choices", () => { + expect(PdfStyleSchema.safeParse({ fontSize: 14 }).success).toBe(false); + expect(PdfStyleSchema.safeParse({ font: "comic-sans" }).success).toBe(false); + expect(PdfStyleSchema.safeParse({ colour: "red" }).success).toBe(false); + }); + + it("give Letter in North America and A4 everywhere else", () => { + expect(paperForCountry("US")).toBe("letter"); + expect(paperForCountry("ca")).toBe("letter"); + expect(paperForCountry("IN")).toBe("a4"); + expect(paperForCountry("DE")).toBe("a4"); + expect(paperForCountry(undefined)).toBe("a4"); + expect(resolveStyle({}, "IN").paper).toBe("a4"); + expect(resolveStyle({ paper: "letter" }, "IN").paper).toBe("letter"); + }); +}); + +describe("a document with a style", () => { + it("is exactly what it always was when no style is given", () => { + const text = tex(); + expect(text).toContain("\\documentclass{article}\n\\usepackage{lucid}\n\\renewcommand"); + expect(text).toContain("\\date{\\today}"); + expect(text).not.toContain("geometry"); + expect(text).not.toContain("lucidaccent"); + }); + + it("with the defaults changes only the paper, which it names", () => { + const text = tex({}); + expect(text).toContain( + "\\documentclass[letterpaper]{article}\n\\usepackage{lucid}\n\\renewcommand", + ); + expect(text).toContain("\\date{\\today}"); + }); + + it("sets the paper and the text size", () => { + expect(tex({ paper: "a4", fontSize: 11 })).toContain("\\documentclass[a4paper,11pt]{article}"); + expect(tex({ fontSize: 12 })).toContain("\\documentclass[letterpaper,12pt]{article}"); + }); + + it("sets the margins with geometry", () => { + expect(tex({ margins: "narrow" })).toContain("\\usepackage[margin=1in]{geometry}"); + expect(tex({ margins: "wide" })).toContain("\\usepackage[margin=2.2in]{geometry}"); + expect(tex({ margins: "normal" })).not.toContain("geometry"); + }); + + it("sets the typeface after the house style has set its own", () => { + expect(tex({ font: "latin-modern" })).not.toContain("\\setmainfont"); + const palatino = tex({ font: "palatino" }); + expect(palatino).toContain("\\setmainfont{texgyrepagella}[Extension=.otf"); + expect(palatino.indexOf("\\usepackage{lucid}")).toBeLessThan(palatino.indexOf("\\setmainfont")); + expect(tex({ font: "times" })).toContain("\\setmainfont{texgyretermes}"); + const sans = tex({ font: "helvetica" }); + expect(sans).toContain("\\setmainfont{texgyreheros}"); + expect(sans).toContain("\\setsansfont{texgyreheros}"); + }); + + it("colours the title and the headings with the accent", () => { + const text = tex({ accent: "navy" }); + expect(text).toContain("\\definecolor{lucidaccent}{HTML}{1F3A68}"); + expect(text).toContain("\\allsectionsfont{\\color{lucidaccent}}"); + expect(text).toContain("\\title{\\textcolor{lucidaccent}{\\underline{Notes}}}"); + expect(tex({ accent: "none" })).toContain("\\title{\\underline{Notes}}"); + for (const accent of ACCENTS) expect(() => tex({ accent })).not.toThrow(); + }); + + it("can leave out the heading numbers and the date", () => { + const text = tex({ numberedHeadings: false, showDate: false }); + expect(text).toContain("\\setcounter{secnumdepth}{0}"); + expect(text).toContain("\\date{}"); + expect(text).not.toContain("\\date{\\today}"); + }); + + it("offers a typeface for every name in the list", () => { + for (const font of FONTS) expect(tex({ font })).toContain("\\begin{document}"); + }); +}); + +describe("the size of the text area", () => { + it("is the house style's on Letter paper at 10 pt", () => { + expect(textSize(resolveStyle({ paper: "letter" }))).toEqual({ width: 345, height: 592 }); + }); + it("grows with the text size, and with narrow margins", () => { + expect(textSize(resolveStyle({ paper: "letter", fontSize: 12 })).width).toBe(390); + expect(textSize(resolveStyle({ paper: "letter", margins: "narrow" }))).toEqual({ + width: 468, + height: 648, + }); + }); + it("is taller on A4", () => { + expect(textSize(resolveStyle({ paper: "a4" })).height).toBeGreaterThan(592); + }); +}); diff --git a/packages/kit/src/latex/style.ts b/packages/kit/src/latex/style.ts new file mode 100644 index 0000000..314d2b2 --- /dev/null +++ b/packages/kit/src/latex/style.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +/** + * How a PDF looks, beyond what a note format decides: the few choices a person makes about their own pages. Everything + * defaults to the house style, and a page made with the defaults is byte for byte what it was before there were choices. + * The choices are written into the preamble of main.tex (after `\usepackage{lucid}`), so `lucid.sty` stays one fixed file. + */ +export const PAPERS = ["auto", "a4", "letter"] as const; +export const FONT_SIZES = [10, 11, 12] as const; +export const FONTS = ["latin-modern", "palatino", "times", "helvetica"] as const; +export const MARGINS = ["normal", "narrow", "wide"] as const; +export const ACCENTS = ["none", "navy", "teal", "crimson", "forest", "plum"] as const; + +export const PdfStyleSchema = z.strictObject({ + /** The paper; "auto" is chosen by the caller from where the computer is (Letter in North America, A4 elsewhere). */ + paper: z.enum(PAPERS).default("auto"), + /** The size of the text in points. */ + fontSize: z.union([z.literal(10), z.literal(11), z.literal(12)]).default(10), + /** The typeface: Latin Modern (the house style), or one of the TeX Gyre faces that come with the PDF engine. */ + font: z.enum(FONTS).default("latin-modern"), + /** The space round the text. */ + margins: z.enum(MARGINS).default("normal"), + /** The colour of the title and the headings (none: black). */ + accent: z.enum(ACCENTS).default("none"), + /** Headings are numbered (1, 1.1). */ + numberedHeadings: z.boolean().default(true), + /** Today's date under the title. */ + showDate: z.boolean().default(true), +}); +export type PdfStyle = z.infer; +/** A style with the paper decided. */ +export type ResolvedPdfStyle = Omit & { paper: "a4" | "letter" }; + +export const DEFAULT_PDF_STYLE: PdfStyle = PdfStyleSchema.parse({}); + +export const FONT_NAMES: Record = { + "latin-modern": "Latin Modern", + palatino: "Palatino", + times: "Times", + helvetica: "Helvetica", +}; + +/** The colours (hex, no #) of the accents: dark enough to read as text on white. */ +export const ACCENT_COLORS: Record, string> = { + navy: "1F3A68", + teal: "0F6B6B", + crimson: "9B1C31", + forest: "2F6B3A", + plum: "6B2F6B", +}; + +/** Paper from a country code: Letter in the countries that use it, A4 everywhere else. */ +export function paperForCountry(country: string | undefined): "a4" | "letter" { + const letter = new Set([ + "US", + "CA", + "MX", + "CL", + "CO", + "VE", + "PH", + "CR", + "PA", + "DO", + "GT", + "SV", + "NI", + "HN", + "PR", + ]); + return letter.has((country ?? "").toUpperCase()) ? "letter" : "a4"; +} + +export function resolveStyle(style: Partial, country?: string): ResolvedPdfStyle { + const full = PdfStyleSchema.parse(style); + return { ...full, paper: full.paper === "auto" ? paperForCountry(country) : full.paper }; +} + +const GYRE = (file: string): string => + `{${file}}[Extension=.otf,UprightFont=*-regular,BoldFont=*-bold,ItalicFont=*-italic,BoldItalicFont=*-bolditalic]`; + +/** The options of `\documentclass`: none for the house style on Letter paper at 10 pt, as it always was. */ +export function classOptions(style: ResolvedPdfStyle): string { + const options = [style.paper === "a4" ? "a4paper" : "letterpaper"]; + if (style.fontSize !== 10) options.push(`${String(style.fontSize)}pt`); + return options.join(","); +} + +/** The lines of the preamble (after `\usepackage{lucid}`) that carry the style; empty for the house style. */ +export function preambleLines(style: ResolvedPdfStyle): string[] { + const lines: string[] = []; + if (style.margins === "narrow") lines.push("\\usepackage[margin=1in]{geometry}"); + if (style.margins === "wide") lines.push("\\usepackage[margin=2.2in]{geometry}"); + if (style.font === "palatino") lines.push(`\\setmainfont${GYRE("texgyrepagella")}`); + if (style.font === "times") lines.push(`\\setmainfont${GYRE("texgyretermes")}`); + if (style.font === "helvetica") + lines.push(`\\setmainfont${GYRE("texgyreheros")}`, `\\setsansfont${GYRE("texgyreheros")}`); + if (style.accent !== "none") { + lines.push( + `\\definecolor{lucidaccent}{HTML}{${ACCENT_COLORS[style.accent]}}`, + "\\usepackage{sectsty}", + "\\allsectionsfont{\\color{lucidaccent}}", + ); + } + if (!style.numberedHeadings) lines.push("\\setcounter{secnumdepth}{0}"); + return lines; +} + +/** + * About how big the text area is, in points: what figures are fitted to. Close, not exact (the exact size is TeX's to decide, + * and every figure is also limited in the LaTeX by fractions of the real size). + */ +export function textSize(style: ResolvedPdfStyle): { width: number; height: number } { + const page = style.paper === "a4" ? { w: 595, h: 842 } : { w: 612, h: 792 }; + const normalWidth = { 10: 345, 11: 360, 12: 390 }[style.fontSize]; + const width = + style.margins === "narrow" + ? page.w - 144 + : style.margins === "wide" + ? page.w - 2 * 158.4 + : normalWidth; + const height = + style.margins === "narrow" + ? page.h - 144 + : style.margins === "wide" + ? page.h - 144 + : Math.round(page.h * 0.7475); + return { width: Math.round(width), height: Math.round(height) }; +} From e64df3fde442c3645ae17d25c7adb6d2d14bd4a7 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:55:15 +0530 Subject: [PATCH 2/3] feat(desktop): choose the look of the PDF in Settings Settings has a "PDF look" group: paper (Auto picks Letter or A4 by the country of the computer), text size, typeface, margins, accent colour, numbered or plain headings, and the date under the title. The choices are kept in settings.json (a file from before has the house style), checked when saved, handed to the PDF engine at once, and the diagrams are fitted to the text area they give. The defaults are the look PDFs always had. An end-to-end test chooses A4, 12 pt, Times, navy, plain headings and no date, makes a real PDF and reads the paper, the embedded font, the missing number and the missing date back from it. Co-Authored-By: Claude Sonnet 5.5 --- apps/desktop/e2e/block-numbers.spec.ts | 4 +- apps/desktop/e2e/pdf-look.spec.ts | 88 +++++++++++ apps/desktop/electron/ipc/index.ts | 9 +- apps/desktop/electron/ipc/types.ts | 3 + apps/desktop/electron/pdf.test.ts | 36 ++++- apps/desktop/electron/pdf.ts | 25 ++- apps/desktop/electron/settings.test.ts | 27 ++++ apps/desktop/electron/settings.ts | 5 + apps/desktop/src/preview/exportPdf.ts | 6 +- apps/desktop/src/preview/fit.test.ts | 3 + apps/desktop/src/preview/fit.ts | 11 +- apps/desktop/src/settings/SettingsDialog.tsx | 157 ++++++++++++++++++- apps/desktop/src/styles.css | 14 ++ 13 files changed, 372 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/e2e/pdf-look.spec.ts diff --git a/apps/desktop/e2e/block-numbers.spec.ts b/apps/desktop/e2e/block-numbers.spec.ts index 2b24f24..8cbcdc1 100644 --- a/apps/desktop/e2e/block-numbers.spec.ts +++ b/apps/desktop/e2e/block-numbers.spec.ts @@ -193,7 +193,8 @@ test("Settings can hide the numbers, and the choice is remembered", async () => await expect(labels(page)).toHaveCount(6); await page.keyboard.press("Meta+,"); const dialog = page.getByRole("dialog", { name: "Settings" }); - await dialog.getByRole("radio", { name: "Hide" }).click(); + // The Appearance group (the PDF look has its own Show and Hide for the date). + await dialog.locator(".appearance").getByRole("radio", { name: "Hide" }).click(); await page.keyboard.press("Escape"); await expect(labels(page)).toHaveCount(0); await page.reload(); @@ -206,6 +207,7 @@ test("Settings can hide the numbers, and the choice is remembered", async () => await page.keyboard.press("Meta+,"); await page .getByRole("dialog", { name: "Settings" }) + .locator(".appearance") .getByRole("radio", { name: "Show" }) .click(); await page.keyboard.press("Escape"); diff --git a/apps/desktop/e2e/pdf-look.spec.ts b/apps/desktop/e2e/pdf-look.spec.ts new file mode 100644 index 0000000..a744fd2 --- /dev/null +++ b/apps/desktop/e2e/pdf-look.spec.ts @@ -0,0 +1,88 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Block } from "@lucid/core"; +import { _electron as electron, expect, test } from "@playwright/test"; +import { autoSaveDialog, downloadPdf, maximizeWindow } from "./helpers"; + +/** + * The PDF look (paper, text size, typeface, margins, accent, numbering, date) is chosen in Settings and is what the next PDF + * has. The PDF is made by the real engine; the tools that read it back are poppler's. + */ +const PDFINFO = "/opt/homebrew/bin/pdfinfo"; +const PDFFONTS = "/opt/homebrew/bin/pdffonts"; + +const text = (value: string) => [{ type: "text" as const, text: value, styles: {} }]; +const blocks: Block[] = [ + { id: "h", type: "heading", props: { level: 1 }, content: text("Caching strategies") }, + { + id: "p", + type: "paragraph", + props: {}, + content: text("Hot keys stay in memory, so most reads never reach the database."), + }, +]; + +test("the look chosen in Settings is the look of the PDF", async () => { + test.skip(!existsSync(PDFINFO) || !existsSync(PDFFONTS), "needs poppler to read the PDF back"); + // A typeface other than the house one is fetched the first time it is used. + test.setTimeout(420_000); + const home = mkdtempSync(join(tmpdir(), "lucid-home-")); + const app = await electron.launch({ + args: [join(__dirname, "../out/main/index.js")], + env: { ...process.env, LUCID_HOME: home }, + }); + try { + const page = await app.firstWindow(); + await maximizeWindow(app, page); + await page.evaluate(async (doc: Block[]) => { + const file = await window.lucid.pages.create("Caching"); + await window.lucid.pages.save({ ...file, doc: { blocks: doc } }); + }, blocks); + await page.reload(); + await page + .getByRole("navigation", { name: "Pages" }) + .getByRole("button", { name: /Caching/ }) + .click(); + + await page.getByRole("button", { name: /Settings/ }).click(); + const dialog = page.getByRole("dialog", { name: "Settings" }); + // The "PDF look" group (Hide is also a choice for the block numbers above it). + const look = dialog.locator(".pdf-look"); + for (const name of ["A4", "12 pt", "Times", "navy", "Plain", "Hide"]) + await look.getByRole("radio", { name, exact: true }).click(); + await dialog.getByRole("button", { name: "Save" }).click(); + await expect(dialog).toBeHidden(); + + // Kept in the settings file, with what was not changed still at its default. + const stored = JSON.parse(readFileSync(join(home, ".lucid", "settings.json"), "utf8")) as { + pdf: Record; + }; + expect(stored.pdf).toEqual({ + paper: "a4", + fontSize: 12, + font: "times", + margins: "normal", + accent: "navy", + numberedHeadings: false, + showDate: false, + }); + + const path = await autoSaveDialog(app); + const words = await downloadPdf(page, path, 400_000); + expect(execFileSync(PDFINFO, [path], { encoding: "utf8" })).toMatch(/\(A4\)/); + expect(execFileSync(PDFFONTS, [path], { encoding: "utf8" })).toContain("TeXGyreTermes"); + expect(words).toContain("Caching strategies"); + // Plain headings: no number in front, and no date under the title. + expect(words).not.toMatch(/1\s+Caching strategies/); + expect(words).not.toMatch(/\b20\d\d\b/); + + // The dialog shows what was chosen when it is opened again. + await page.getByRole("button", { name: /Settings/ }).click(); + await expect(look.getByRole("radio", { name: "Times", exact: true })).toBeChecked(); + await expect(look.getByRole("radio", { name: "navy", exact: true })).toBeChecked(); + } finally { + await app.close(); + } +}); diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index ff7b501..081c4f3 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -117,6 +117,8 @@ export function registerIpc(): void { convertSvg: svgToPdf, author: s.author, footerRight: s.footerRight, + style: s.pdf, + country: app.getLocaleCountryCode(), onProgress: (progress) => { engineStatus.report(progress); for (const w of BrowserWindow.getAllWindows()) { @@ -137,6 +139,7 @@ export function registerIpc(): void { const publicSettings = (s: Settings): PublicSettings => ({ author: s.author, footerRight: s.footerRight, + pdf: s.pdf, vocabulary: s.vocabulary, llm: { baseUrl: process.env["LUCID_LLM_URL"] ?? s.llm.baseUrl, @@ -157,7 +160,11 @@ export function registerIpc(): void { ? { ...sent, llm: { ...sent.llm, baseUrl: stored.llm.baseUrl } } : input; const saved = await saveSettings(libraryDir(), checked); - (await compiler).setStyle({ author: saved.author, footerRight: saved.footerRight }); + (await compiler).setStyle({ + author: saved.author, + footerRight: saved.footerRight, + style: saved.pdf, + }); (await library).setAuthor(saved.author); return { ok: true, settings: publicSettings(saved) }; } catch (error) { diff --git a/apps/desktop/electron/ipc/types.ts b/apps/desktop/electron/ipc/types.ts index 4042404..083e305 100644 --- a/apps/desktop/electron/ipc/types.ts +++ b/apps/desktop/electron/ipc/types.ts @@ -1,6 +1,7 @@ // Single source of truth for the preload bridge (`window.lucid`). Imported by main, preload and // the renderer, so it must stay free of Electron and Node imports. import type { BlockPatch, LucidDoc, LucidFile } from "@lucid/core"; +import type { PdfStyle } from "@lucid/kit"; export interface PageSummary { id: string; @@ -101,6 +102,8 @@ export type AgentReply = export interface PublicSettings { author: string; footerRight: string; + /** How PDFs look (paper, text size, typeface, margins, accent, numbering, date). */ + pdf: PdfStyle; vocabulary: string[]; llm: { baseUrl: string; model: string; contextLength: number }; /** The address comes from the LUCID_LLM_URL environment variable (tests, development) and is not changed here. */ diff --git a/apps/desktop/electron/pdf.test.ts b/apps/desktop/electron/pdf.test.ts index df4a575..03909c4 100644 --- a/apps/desktop/electron/pdf.test.ts +++ b/apps/desktop/electron/pdf.test.ts @@ -2,7 +2,7 @@ import { chmod, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getFormat, registry } from "@lucid/kit"; +import { DEFAULT_PDF_STYLE, getFormat, registry } from "@lucid/kit"; import { describe, expect, it } from "vitest"; import type { LucidDoc } from "@lucid/core"; import { @@ -312,6 +312,40 @@ shellScripts("PdfCompiler with stand-in tools", () => { expect(next).toContain("\\author{Asha \\& Co}"); }); + it("sets the page in the look chosen in Settings, and the next PDF in a changed one", async () => { + const t = await script("exit 1"); + const { c, workRoot } = await compiler(t); + await c.compile(req("page-look", "text")); + // No look was ever set: the house style, as it always was. + expect(await readFile(join(workRoot, "page-look", "main.tex"), "utf8")).toContain( + "\\documentclass{article}", + ); + c.setStyle({ + author: "Asha", + footerRight: "Notes", + style: { ...DEFAULT_PDF_STYLE, paper: "a4", fontSize: 12, accent: "teal", font: "palatino" }, + }); + await c.compile(req("page-look", "text")); + const tex = await readFile(join(workRoot, "page-look", "main.tex"), "utf8"); + expect(tex).toContain("\\documentclass[a4paper,12pt]{article}"); + expect(tex).toContain("\\setmainfont{texgyrepagella}"); + expect(tex).toContain("lucidaccent"); + }); + + it("decides the paper from the country when the look says auto", async () => { + const t = await script("exit 1"); + const make = async (country: string) => { + const { c, workRoot } = await compiler(t, stubConvert, { + style: DEFAULT_PDF_STYLE, + country, + }); + await c.compile(req(`page-${country}`, "text")); + return readFile(join(workRoot, `page-${country}`, "main.tex"), "utf8"); + }; + expect(await make("US")).toContain("\\documentclass[letterpaper]{article}"); + expect(await make("IN")).toContain("\\documentclass[a4paper]{article}"); + }); + it("sets the page in its note format: the fields under the title, and no empty sections", async () => { const t = await script("exit 1"); const { c, workRoot } = await compiler(t); diff --git a/apps/desktop/electron/pdf.ts b/apps/desktop/electron/pdf.ts index 1b8036b..ba2910c 100644 --- a/apps/desktop/electron/pdf.ts +++ b/apps/desktop/electron/pdf.ts @@ -2,7 +2,14 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; -import { LUCID_STY, registry, renderDocument, type Format } from "@lucid/kit"; +import { + LUCID_STY, + registry, + renderDocument, + resolveStyle, + type Format, + type PdfStyle, +} from "@lucid/kit"; import type { LucidDoc } from "@lucid/core"; import type { PdfProgress } from "./ipc/types"; @@ -38,6 +45,10 @@ export interface PdfOptions { convertSvg: (svg: string) => Promise; author: string; footerRight: string; + /** How PDFs look (paper, text size, typeface, margins, accent, numbering, date); the house style when absent. */ + style?: PdfStyle; + /** The country of the computer, for the paper when the style says "auto" (Letter in North America, A4 elsewhere). */ + country?: string | undefined; /** The longest a single engine run may take in all (default 30 minutes: a first start downloads for about six). */ timeoutMs?: number; /** The longest the engine may print nothing before it is taken to be stuck (default 90 s). */ @@ -272,10 +283,16 @@ export class PdfCompiler { } } - /** The author and footer of PDFs made from now on (the Settings window changed them). */ - setStyle(style: { author: string; footerRight: string }): void { + /** The author, footer and look of PDFs made from now on (the Settings window changed them). */ + setStyle(style: { author: string; footerRight: string; style?: PdfStyle }): void { this.options.author = style.author; this.options.footerRight = style.footerRight; + if (style.style) this.options.style = style.style; + } + + /** The look of the next PDF, with the paper decided; none when no look was ever set (the house style as it was). */ + private look(): ReturnType | undefined { + return this.options.style ? resolveStyle(this.options.style, this.options.country) : undefined; } async compile(req: CompileRequest): Promise { @@ -335,6 +352,7 @@ export class PdfCompiler { info: req.info, figureWidths, figureHeights, + style: this.look(), }); const rendered: Record = {}; const warnings: string[] = []; @@ -372,6 +390,7 @@ export class PdfCompiler { info: req.info, figureWidths, figureHeights, + style: this.look(), }, rendered, ); diff --git a/apps/desktop/electron/settings.test.ts b/apps/desktop/electron/settings.test.ts index 69519b4..70b7514 100644 --- a/apps/desktop/electron/settings.test.ts +++ b/apps/desktop/electron/settings.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, readFile, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { DEFAULT_PDF_STYLE } from "@lucid/kit"; import { SettingsError, loadSettings, saveSettings } from "./settings"; describe("loadSettings", () => { @@ -24,6 +25,20 @@ describe("loadSettings", () => { expect(await loadSettings(dir)).toMatchObject({ author: "Asha", footerRight: "" }); }); + it("gives the house style to a settings file from before there were PDF choices, and keeps partial ones", async () => { + const dir = await mkdtemp(join(tmpdir(), "lucid-set-")); + await mkdir(join(dir, ".lucid")); + const file = join(dir, ".lucid", "settings.json"); + await writeFile(file, JSON.stringify({ author: "Asha" }), "utf8"); + expect((await loadSettings(dir)).pdf).toEqual(DEFAULT_PDF_STYLE); + await writeFile(file, JSON.stringify({ pdf: { paper: "a4", font: "times" } }), "utf8"); + expect((await loadSettings(dir)).pdf).toEqual({ + ...DEFAULT_PDF_STYLE, + paper: "a4", + font: "times", + }); + }); + it("falls back to the defaults for a damaged file without overwriting it", async () => { const dir = await mkdtemp(join(tmpdir(), "lucid-set-")); await mkdir(join(dir, ".lucid")); @@ -37,6 +52,7 @@ describe("saveSettings", () => { const valid = { author: "Asha", footerRight: "Notes", + pdf: { ...DEFAULT_PDF_STYLE, paper: "a4", fontSize: 11, accent: "navy", showDate: false }, vocabulary: ["vLLM", "Kafka", "vLLM"], llm: { baseUrl: "http://localhost:1234/", model: "other/model-7b", contextLength: 16384 }, }; @@ -53,6 +69,7 @@ describe("saveSettings", () => { expect(saved).toMatchObject({ author: "Asha", footerRight: "Notes", + pdf: { paper: "a4", fontSize: 11, accent: "navy", showDate: false }, vocabulary: ["vLLM", "Kafka"], tectonicPath: "/custom/tectonic", llm: { @@ -91,6 +108,16 @@ describe("saveSettings", () => { await bad({ vocabulary: Array.from({ length: 201 }, (_, i) => `w${String(i)}`) }), ).toBeInstanceOf(SettingsError); expect(await bad({ tectonicPath: "/bin/sh" })).toBeInstanceOf(SettingsError); + expect(await bad({ pdf: { ...DEFAULT_PDF_STYLE, fontSize: 14 } })).toBeInstanceOf( + SettingsError, + ); + expect(await bad({ pdf: { ...DEFAULT_PDF_STYLE, font: "comic-sans" } })).toBeInstanceOf( + SettingsError, + ); + expect(await bad({ pdf: { ...DEFAULT_PDF_STYLE, template: "/etc/passwd" } })).toBeInstanceOf( + SettingsError, + ); + expect(await bad({ pdf: undefined })).toBeInstanceOf(SettingsError); expect(await loadSettings(dir)).toEqual(before); }); }); diff --git a/apps/desktop/electron/settings.ts b/apps/desktop/electron/settings.ts index ff3343f..034cedb 100644 --- a/apps/desktop/electron/settings.ts +++ b/apps/desktop/electron/settings.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { DEFAULT_PDF_STYLE, PdfStyleSchema } from "@lucid/kit"; import { z } from "zod"; /** User settings in `/.lucid/settings.json`. Missing file or fields fall back to the defaults. */ @@ -8,6 +9,8 @@ export const SettingsSchema = z.object({ author: z.string().default(""), /** Right-hand footer text of exported PDFs (optional; the page number is on the left). */ footerRight: z.string().default(""), + /** How PDFs look: paper, text size, typeface, margins, accent, numbering and date (the house style by default). */ + pdf: PdfStyleSchema.default(DEFAULT_PDF_STYLE), /** Words speech recognition tends to get wrong (names, tools); kept as spoken when the notes are written. */ vocabulary: z.array(z.string()).default([]), /** The Tectonic to run; empty means the one that comes with Lucid, or an installed one. */ @@ -83,6 +86,7 @@ const LocalAddress = z export const EditableSettingsSchema = z.strictObject({ author: oneLine(80), footerRight: oneLine(40), + pdf: PdfStyleSchema, vocabulary: z.array(oneLine(60).min(1)).max(200, "at most 200 words"), llm: z.strictObject({ baseUrl: LocalAddress, @@ -115,6 +119,7 @@ export async function saveSettings(libraryDir: string, input: unknown): Promise< ...current, author: parsed.data.author, footerRight: parsed.data.footerRight, + pdf: parsed.data.pdf, vocabulary: [...new Set(parsed.data.vocabulary)], llm: { ...current.llm, ...parsed.data.llm }, }; diff --git a/apps/desktop/src/preview/exportPdf.ts b/apps/desktop/src/preview/exportPdf.ts index be68ff3..cabff91 100644 --- a/apps/desktop/src/preview/exportPdf.ts +++ b/apps/desktop/src/preview/exportPdf.ts @@ -1,7 +1,8 @@ import type { LucidDoc } from "@lucid/core"; -import { collectFigures } from "@lucid/kit"; +import { collectFigures, resolveStyle, textSize } from "@lucid/kit"; import { pdfBox } from "./fit"; import { renderFitted } from "./mermaid"; +import { peekSettings } from "../settings/settingsStore"; export type PdfMade = { ok: true; pdf: Uint8Array; warnings: string[] } | { ok: false; message: string }; @@ -16,7 +17,8 @@ export async function makePdf( try { const figures: Record = {}; // A big diagram is drawn in the direction that has to be shrunk least to fit the page, not squeezed until it cannot be read. - const box = pdfBox(); + const look = peekSettings()?.pdf; + const box = pdfBox(look ? textSize(resolveStyle(look)) : undefined); for (const f of collectFigures(doc.blocks)) figures[f.blockId] = (await renderFitted(f.mermaid, "pdf", box)).svg; const result = await window.lucid.pdf.compile({ diff --git a/apps/desktop/src/preview/fit.test.ts b/apps/desktop/src/preview/fit.test.ts index 174e2ef..d522a8a 100644 --- a/apps/desktop/src/preview/fit.test.ts +++ b/apps/desktop/src/preview/fit.test.ts @@ -71,4 +71,7 @@ describe("the space on a PDF page", () => { it("is 1.3 times the text width and 0.85 of the text height, in the drawing's pixels", () => { expect(pdfBox()).toEqual({ width: 598, height: 671 }); }); + it("follows a text area of another size (wider margins, another paper)", () => { + expect(pdfBox({ width: 468, height: 648 })).toEqual({ width: 811, height: 734 }); + }); }); diff --git a/apps/desktop/src/preview/fit.ts b/apps/desktop/src/preview/fit.ts index af800df..fbe3f96 100644 --- a/apps/desktop/src/preview/fit.ts +++ b/apps/desktop/src/preview/fit.ts @@ -70,11 +70,12 @@ export const minWidthFor = (size: Size): number => Math.round(size.width * MIN_S /** * The most a figure can get on a PDF page, in the drawing's pixels (1 px is 0.75 pt): the text area of the house style is - * about 345 pt wide and 592 pt high, a big figure may reach 1.3 times the text width (into the margins) and 0.85 of the - * text height (a taller one could not be on a page with its caption). The same limits as in the LaTeX. + * about 345 pt wide and 592 pt high (or what the chosen paper, margins and text size give), a big figure may reach 1.3 times the + * text width (into the margins) and 0.85 of the text height (a taller one could not be on a page with its caption). The same + * limits as in the LaTeX. */ -export function pdfBox(): Box { - const textWidthPx = 345 / 0.75; - const textHeightPx = 592 / 0.75; +export function pdfBox(text: Size = { width: 345, height: 592 }): Box { + const textWidthPx = text.width / 0.75; + const textHeightPx = text.height / 0.75; return { width: Math.round(1.3 * textWidthPx), height: Math.round(0.85 * textHeightPx) }; } diff --git a/apps/desktop/src/settings/SettingsDialog.tsx b/apps/desktop/src/settings/SettingsDialog.tsx index 59b3fb3..ca5eee7 100644 --- a/apps/desktop/src/settings/SettingsDialog.tsx +++ b/apps/desktop/src/settings/SettingsDialog.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; +import { ACCENT_COLORS, FONT_NAMES, type PdfStyle } from "@lucid/kit"; import type { PublicSettings } from "../../electron/ipc/types"; import { setFont, @@ -22,6 +23,63 @@ const FONT_OPTIONS: { value: Typeface; label: string }[] = [ { value: "serif", label: "Serif" }, ]; +/** A row of one-of-several choices in the look of the Appearance rows. */ +function Choice(props: { + label: string; + value: T; + options: readonly (readonly [T, string])[]; + onChange: (value: T) => void; +}) { + const id = useId(); + return ( +
+ {props.label} +
+ {props.options.map(([value, text]) => ( + + ))} +
+
+ ); +} + +const PAPER_OPTIONS = [ + ["auto", "Auto"], + ["a4", "A4"], + ["letter", "Letter"], +] as const; +const SIZE_OPTIONS = [ + [10, "10 pt"], + [11, "11 pt"], + [12, "12 pt"], +] as const; +const TYPEFACE_OPTIONS = (Object.keys(FONT_NAMES) as PdfStyle["font"][]).map( + (key) => [key, FONT_NAMES[key]] as const, +); +const MARGIN_OPTIONS = [ + ["narrow", "Narrow"], + ["normal", "Normal"], + ["wide", "Wide"], +] as const; +const HEADING_OPTIONS = [ + [true, "Numbered"], + [false, "Plain"], +] as const; +const DATE_OPTIONS = [ + [true, "Show"], + [false, "Hide"], +] as const; + interface Props { settings: PublicSettings; onClose: () => void; @@ -31,6 +89,10 @@ interface Props { export function SettingsDialog({ settings, onClose }: Props) { const [author, setAuthor] = useState(settings.author); const [footer, setFooter] = useState(settings.footerRight); + const [pdf, setPdf] = useState(settings.pdf); + const changePdf = (change: Partial) => { + setPdf((before) => ({ ...before, ...change })); + }; const [address, setAddress] = useState(settings.llm.baseUrl); const [model, setModel] = useState(settings.llm.model); const [context, setContext] = useState(String(settings.llm.contextLength)); @@ -59,6 +121,7 @@ export function SettingsDialog({ settings, onClose }: Props) { const result = await saveSettings({ author, footerRight: footer, + pdf, vocabulary: words .split(/\r?\n/) .map((w) => w.trim()) @@ -160,8 +223,8 @@ export function SettingsDialog({ settings, onClose }: Props) { - Applied at once. Mono is the default; PDFs keep their own typesetting. Block numbers - (B1, B2…) sit in the page margin; say “in block 3, …” to talk about one. + Applied at once. Mono is the default; the look of PDFs is set below. Block numbers (B1, + B2…) sit in the page margin; say “in block 3, …” to talk about one. @@ -233,6 +296,94 @@ export function SettingsDialog({ settings, onClose }: Props) { +
+ PDF look + { + changePdf({ paper }); + }} + /> + { + changePdf({ fontSize }); + }} + /> + { + changePdf({ font }); + }} + /> + { + changePdf({ margins }); + }} + /> +
+ Accent colour +
+ + {(Object.keys(ACCENT_COLORS) as Exclude[]).map((name) => ( + + ))} +
+
+ { + changePdf({ numberedHeadings }); + }} + /> + { + changePdf({ showDate }); + }} + /> + + Applies to the next PDF. A typeface other than Latin Modern is fetched the first time it + is used, so that PDF needs the internet once. Auto picks Letter or A4 by where this + computer is. + +
+
Words to expect