Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@
All notable changes to Lucid are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and Lucid uses [semantic versioning](https://semver.org).

## [Unreleased]

### Added

- **The look of the PDF is yours to set** (Settings, "PDF look"): paper (Auto picks Letter or A4 by where the computer is), text size, typeface (Latin Modern, Palatino, Times or Helvetica), margins, an accent colour for the title and headings, numbered or plain headings, and the date under the title. The defaults are the look PDFs always had.

### Changed

- Tables in the PDF are set ragged right with a little more room between rows, and the bars of a chart use the same greys as on screen.
- Dictation: the docs say that any app that pastes works, and list the free and open-source ones (OpenWhispr, VoiceNative) next to Wispr Flow.
- The picture of each note format (`docs/format-samples`) is made with the real app, with real diagrams and every section filled.
- The list of bundled licences is made when the app is packaged and comes with the app (Help, Licences of the Software Lucid Uses); it is no longer kept in the repository.

### Fixed

- A diagram almost as tall as the page no longer leaves an empty page in the PDF.

## [0.2.0] - 2026-10-12

Lucid now runs on Windows and Linux as well as the Mac, has clearer panels and a new look for the list of pages, and has a website.
Expand Down Expand Up @@ -60,5 +77,6 @@ The first public release.
- The first PDF on a computer downloads Tectonic's typesetting files (about five minutes, once).
- Only `qwen/qwen3.8-27b` has been evaluated. A request takes 10 to 25 seconds on a laptop.

[Unreleased]: https://github.com/triggeredcode/lucid/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/triggeredcode/lucid/releases/tag/v0.2.0
[0.1.0]: https://github.com/triggeredcode/lucid/releases/tag/v0.1.0
4 changes: 3 additions & 1 deletion apps/desktop/e2e/block-numbers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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");
Expand Down
88 changes: 88 additions & 0 deletions apps/desktop/e2e/pdf-look.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
};
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();
}
});
9 changes: 8 additions & 1 deletion apps/desktop/electron/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron/ipc/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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. */
Expand Down
36 changes: 35 additions & 1 deletion apps/desktop/electron/pdf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 22 additions & 3 deletions apps/desktop/electron/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -38,6 +45,10 @@ export interface PdfOptions {
convertSvg: (svg: string) => Promise<Uint8Array>;
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). */
Expand Down Expand Up @@ -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<typeof resolveStyle> | undefined {
return this.options.style ? resolveStyle(this.options.style, this.options.country) : undefined;
}

async compile(req: CompileRequest): Promise<CompileResult> {
Expand Down Expand Up @@ -335,6 +352,7 @@ export class PdfCompiler {
info: req.info,
figureWidths,
figureHeights,
style: this.look(),
});
const rendered: Record<string, string> = {};
const warnings: string[] = [];
Expand Down Expand Up @@ -372,6 +390,7 @@ export class PdfCompiler {
info: req.info,
figureWidths,
figureHeights,
style: this.look(),
},
rendered,
);
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/electron/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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"));
Expand All @@ -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 },
};
Expand All @@ -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: {
Expand Down Expand Up @@ -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);
});
});
5 changes: 5 additions & 0 deletions apps/desktop/electron/settings.ts
Original file line number Diff line number Diff line change
@@ -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 `<library>/.lucid/settings.json`. Missing file or fields fall back to the defaults. */
Expand All @@ -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. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
};
Expand Down
Loading
Loading