24 Commits
Author SHA1 Message Date
github-actions[bot] 9e714c8997 chore(release): v0.1.7 2026-06-22 21:14:30 +00:00
pj cc26e1d51c changes 2026-06-22 17:12:44 -04:00
github-actions[bot] 530e260903 chore(release): v0.1.6 2026-06-22 20:52:01 +00:00
pj 74a684498a changes 2026-06-22 16:51:39 -04:00
pj 3fccb17060 fix(ci): provision google-credentials.json before release build
include_str! embeds google-credentials.json at compile time, but the
file is gitignored, so CI checkouts had no file and the Rust crate
failed to compile on all three platforms. Write it from the
GOOGLE_CREDENTIALS secret, falling back to the committed example
placeholders so the build always succeeds.
2026-06-22 16:50:28 -04:00
pj 9acb3aae3c changes 2026-06-22 16:42:03 -04:00
github-actions[bot] fb6f14165f chore(release): v0.1.5 2026-06-22 20:20:02 +00:00
pj c79897c3db changes 2026-06-22 16:19:24 -04:00
pj 5cc083d670 changes 2026-06-22 16:11:43 -04:00
pj ecc94e6c31 refactor(fonts): use DEFAULT_FONTS as settings fallback
Avoids leaking a pairing's id/label into the stored book fonts.
2026-06-22 14:34:45 -04:00
pj baf1154683 feat(fonts): embed selected fonts in EPUB export
Embed each bundled font the book uses (fetched in the webview) with its
own @font-face, point body text and headings at the chosen families, and
drop the hardcoded Literata injection in the packager. System fonts are
referenced by name with a serif fallback since they cannot be embedded.
2026-06-22 14:30:36 -04:00
pj 7a2d7c6aff feat(fonts): honor selected fonts in PDF export
Interpolate the book's body and heading families into the Typst
preamble, title page, cover, and contents. Embed the needed bundled
fonts and load any chosen system fonts (via fontdb) into the Typst
engine so PDF preview and export match the on-screen fonts.
2026-06-22 14:27:51 -04:00
pj 9a38976000 feat(fonts): add font picker to book setup
Add a Typography section to book setup with one-click preset pairings,
an advanced body/heading picker, and a live sample. Enumerate installed
fonts via a new list_system_fonts Tauri command (fontdb) so they appear
alongside the bundled set, with a note that system fonts may not embed
in exports.
2026-06-22 14:24:09 -04:00
pj 28367b6670 feat(fonts): apply per-book body and heading fonts on screen
Split heading typography onto a new --font-heading token and set both
--font-book and --font-heading from the active book's fonts when it
opens or its setup changes. Declare @font-face for the new bundled
fonts.
2026-06-22 14:19:01 -04:00
pj 124c1947c1 feat(fonts): add bundled fonts and per-book font model
Add EB Garamond, Lora, Source Serif 4, and Fraunces (OFL variable
fonts) and a font catalog with body/heading pairings. Store the chosen
fonts per book in BookSettings.fonts, defaulting to Literata so existing
books are unchanged.
2026-06-22 14:18:57 -04:00
github-actions[bot] 33ef53936e chore(release): v0.1.4 2026-06-22 17:44:52 +00:00
pj 738eb9c9cf Quality bugfix plan (#2)
* fix(persist): atomic+durable .margin writes

Route save_book, write_file, write_bytes through a shared atomic_write
helper: write to a temp file, fsync, then rename into place so a crash
mid-write can never leave a truncated or empty manuscript. Rotate the
prior copy to .bak for manuscripts (not exports) so one good copy
always survives.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(editor): isolate undo history per chapter

Key the editor by chapter id so each chapter gets a fresh TipTap
instance with its own history. Previously a single instance was reused
across chapters, so the content swap entered the undo stack and Ctrl+Z
after switching restored the prior chapter's doc into the current one,
which autosave then persisted.

With remount, save the cursor/scroll position on unmount (from a ref,
not the editor, which is destroyed by then) and load it on mount,
replacing the in-place chapter-switch bookkeeping.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* feat(editor): persist active chapter per book

Remember which chapter was open per book and restore it on reopen,
falling back to the first chapter when the saved id is no longer valid.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(persist): flush pending save on navigate-away and quit

Edits are saved on an 800ms debounce, so leaving a book within that
window lost the latest changes. Flush a dirty book before openBook /
closeBook swap state (covers All books, open-another, Cmd+N), and add a
window close-request handler that saves before destroying on quit.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(persist): guard markSaved against in-flight edits

markSaved cleared dirty unconditionally, so an edit made while a save
was in flight had its dirty flag wiped and its follow-up save dropped.
Only clear dirty when the book reference is unchanged since the save
started (immutable updates give a fresh reference per edit).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(persist): persist new books on create

A freshly created book lived only in memory until the first edit, so
quitting beforehand lost it and it never appeared in the library. Save
it to disk on creation via a shared createAndOpenBook used by both the
Library card and the New Book menu action.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(export): correct list nesting, marks, line-start escaping, svg type

Four export-correctness fixes intermingled across the Typst and EPUB
renderers:

- H7: neutralize newlines in escaped text and apply leading-marker
  escaping (= - + / and N.) to the first text node of every block
  (paragraph, heading, list item, blockquote) and figure captions, even
  when that run carries a mark, so author text can never inject Typst
  markup or phantom headings/TOC entries.
- H9: render lists recursively so nested lists survive and multi-
  paragraph list items keep their paragraph breaks (Typst functional
  list()/enum(); EPUB nested <ul>/<ol> and multiple <p> in <li>).
- H10: render strike, underline and inline code marks in both exporters
  (Typst #strike/#underline/#raw; EPUB <s>/<u>/<code> with css).
- H8b: normalize +-bearing image subtypes so an imported svg+xml cover
  becomes cover.svg with the correct media type.

Validated the generated Typst constructs compile with typst 0.15.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* feat(export): surface typst warnings and glyph-coverage notice

Typst compile warnings were discarded. Capture them via the Warned
result and emit a pdf-warnings event (only from the export path, not
the live preview) that App shows as a non-fatal toast; the export still
succeeds. Also scan book text before reporting success and warn when it
contains scripts the embedded Latin fonts cannot render (CJK, Arabic,
Hebrew, Devanagari, Thai, emoji).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(ui): handle library-open errors

Opening a book card swallowed failures: a corrupt or unreadable file
failed silently and normalizeBook could throw on a missing chapters
array. Surface load errors as a toast, throw a clear message on
unparseable JSON, and make normalizeBook tolerant of missing chapters,
metadata and settings.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(preview): destroy pdf.js document on recompile

The preview created a new PDF document on every recompile but never
destroyed the old one, leaking the document and its worker transport.
Keep the loading task and destroy it on effect cleanup.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(library): surface unreadable books

list_books silently skipped any .margin file it could not read, parse,
or that lacked an id, so a truncated manuscript just vanished. Emit a
corrupt placeholder entry instead and show it in the library as a
non-openable card with a recovery hint pointing at the .bak backup.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(persist): schema-version guard and editor content check

A book file's declared schema was never read, so a file from a newer
app version would be opened, normalized and autosaved over in the old
format. Refuse to open books whose schema is newer than supported.
Enable the editor content check so chapters with unreadable content
warn the user instead of silently loading stripped content that the
next edit would persist.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(editor): prune chapter positions on delete and guard quota

Saved cursor/scroll and active-chapter entries accumulated per book
forever and were never removed when a book was deleted. Add
clearPositions and call it from the delete flow, and guard localStorage
writes against quota or unavailable storage.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(security): validate book id paths

load/save/delete_book built paths from the raw id, and ids round-trip
from file JSON, so a crafted id could escape the library directory.
Route all three through a book_path helper that rejects anything but
[A-Za-z0-9_-].

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* chore(security): restrictive content security policy

security.csp was null (no CSP). Add a restrictive policy as
defense-in-depth: self-only by default, data:/blob: images for cover
and figure URLs, inline styles for React/inline style attributes, and
blob: workers for pdf.js. Needs verification against a production build
(covers, preview, export, IPC).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): preserve link, underline, strike and code marks

EPUB import only mapped bold and italic; a, u, s/del/strike and code
elements lost their formatting (kept as plain text). Map them to the
matching marks, consistent with what the exporters now render.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): keep inline svg and unresolved image alt text

Inline svg elements were dropped entirely and an img whose source could
not be resolved was discarded. Serialize a standalone svg to a
data:image/svg+xml figure, and fall back to an unresolved image's alt
text as a paragraph instead of losing it.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): keep table rows together

Tables were flattened to one paragraph per cell, losing row grouping.
Join each row's cells into a single paragraph so the row structure
survives (the model has no table node).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): import the cover image

The EPUB cover was discarded and replaced with the default generated
cover. Locate the cover via the manifest cover-image property or the
meta name="cover" reference, resolve it to a data URI, and import it as
an image cover.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): validate ISBN with checksum

The importer accepted any 9-13 digit run as the ISBN, so a UUID or
arbitrary number became a bogus ISBN. Prefer scheme-tagged identifiers
and accept only values that pass the ISBN-10/13 length and checksum
test, leaving it empty otherwise.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* fix(import): isolate bad chapters and bound nesting depth

A deeply nested document could overflow the stack and abort the entire
import. Bound block recursion depth and wrap each chapter build in a
try/catch so one unreadable chapter is skipped instead of failing the
whole import.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk

* changes
2026-06-22 13:44:30 -04:00
pj 61bd7cf6e5 changes 2026-06-22 13:03:11 -04:00
github-actions[bot] b4097cc47f chore(release): v0.1.3 2026-06-22 16:50:04 +00:00
pj 72d698e427 changes 2026-06-22 12:40:29 -04:00
pj 8b83f80d52 fix(editor): persist per-chapter cursor and scroll across restarts
Positions were kept in an in-memory Map, so they were lost on quit and
the cursor jumped to the top on reopen. Store them in localStorage
(keyed by book + chapter, like theme/width) and restore on app open,
not just on in-session chapter switches. Also save the current chapter
continuously (debounced on selection + scroll) so closing the app
records where you left off, and re-apply scroll after fonts load.
2026-06-22 12:09:34 -04:00
pj 39609fbd09 perf(proofing): run proof_text on a blocking thread
proof_text loaded the dictionary and ran spell/grammar checks
synchronously inside an async command, blocking an async-runtime worker
and holding the engine mutex for the whole call. Move the work to
spawn_blocking so IPC stays responsive under load.
2026-06-22 12:01:25 -04:00
pj 3177b2f1eb fix(preview): show render errors instead of a blank pane
The render loop swallowed getDocument/render rejections, leaving the
preview silently blank with no signal about what failed. Surface the
error in the pane and recover on the next compile. Also drop the
redundant canvasContext arg (pdf.js v6 renders via the canvas itself).
2026-06-22 12:01:25 -04:00
pj 19c4823d3d changes 2026-06-22 11:56:31 -04:00
76 changed files with 4407 additions and 465 deletions

No files matched your search

+13
View File
@@ -137,6 +137,19 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Provision Google credentials
shell: bash
env:
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
run: |
if [ -n "$GOOGLE_CREDENTIALS" ]; then
printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json
echo "Wrote google-credentials.json from GOOGLE_CREDENTIALS secret."
else
cp google-credentials.example.json google-credentials.json
echo "::warning::GOOGLE_CREDENTIALS secret not set — embedding placeholder credentials; Google Drive backup will be disabled in this release."
fi
- name: Build and upload - name: Build and upload
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
+4
View File
@@ -26,6 +26,10 @@ dist-ssr
# Tauri build output # Tauri build output
src-tauri/target/ src-tauri/target/
# Google OAuth desktop client (real values, never commit)
/google-credentials.json
/client_secret_*.json
# Screenshots & Playwright MCP artifacts # Screenshots & Playwright MCP artifacts
.playwright-mcp/ .playwright-mcp/
/*.png /*.png
+1 -1
View File
@@ -10,7 +10,7 @@
## Git Commit Rules ## Git Commit Rules
- Do not make branches, commit in main only
- Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message` - Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message`
- Never use `git add .` or `git add -A`. Always stage specific files by name. - Never use `git add .` or `git add -A`. Always stage specific files by name.
- Don't batch multiple unrelated changes into one commit. - Don't batch multiple unrelated changes into one commit.
- Commit early and often. A working 5-line change is better than a pending 200-line change.
+1 -35
View File
@@ -1,37 +1,3 @@
# margin # margin
An offline desktop app for writing books and exporting them to print-ready PDF and reflowable EPUB — a focused, free alternative to InDesign for text-first books (novels, non-fiction). Write your book. Own every word. A calm, offline studio to write, format, and publish your book to every store and to print.
## Stack
- **Tauri 2** (Rust shell) — offline by construction; the entire export pipeline runs locally, nothing hits the network.
- **React + TipTap** — the semantic writing surface.
- **Typst** (embedded as a Rust crate) — print-ready PDF with real book typography.
- **Built-in EPUB3** generator + zip packager.
One semantic document feeds three renderers — the editor, the PDF, and the EPUB — from a single design-token theme (**Quiet Press**: Literata for the page, Hanken Grotesk for the chrome). Both fonts are bundled.
## Develop
```sh
pnpm install
pnpm tauri dev # the real desktop app: live PDF dock, file dialogs, PDF/EPUB export
pnpm dev # browser-only UI preview (Tauri APIs are inert)
```
The first `tauri dev` build is slow because it compiles Typst.
## Using it
- A book saves as a single `.margin` file (JSON). Open / Save with ⌘O / ⌘S.
- Click the title in the top bar for **Book setup** (title, author, ISBN, trim size, language).
- Insert images from the toolbar and choose a placement (inline / full-width / full-page / float).
- Export to **PDF** or **EPUB** from the titlebar menu.
## Layout
- `src/editor/` — TipTap editor, extensions, figure block, floating toolbar.
- `src/components/` — app shell, preview dock, book-setup panel, pdf.js renderer.
- `src/export/` — `typst.ts` (book → Typst source), `epub.ts` (book → EPUB3 files), `exporters.ts`.
- `src/store/`, `src/model/` — document model and state.
- `src-tauri/src/` — `pdf.rs` (Typst compile), `epub.rs` (zip packager), `project.rs` (file IO).
+11
View File
@@ -0,0 +1,11 @@
{
"installed": {
"client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"project_id": "your-project-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "YOUR_CLIENT_SECRET",
"redirect_uris": ["http://127.0.0.1"]
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "margin-app", "name": "margin-app",
"private": true, "private": true,
"version": "0.1.2", "version": "0.1.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+135 -3
View File
@@ -3089,8 +3089,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -3754,6 +3756,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -4632,6 +4635,12 @@ dependencies = [
"hashbrown 0.17.1", "hashbrown 0.17.1",
] ]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lzma-rs" name = "lzma-rs"
version = "0.3.0" version = "0.3.0"
@@ -4704,12 +4713,16 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "margin-app" name = "margin-app"
version = "0.1.1" version = "0.1.5"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"fontdb",
"harper-core", "harper-core",
"rand 0.8.6",
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"spellbook", "spellbook",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -4720,6 +4733,7 @@ dependencies = [
"typst", "typst",
"typst-as-lib", "typst-as-lib",
"typst-pdf", "typst-pdf",
"url",
"zip 2.4.2", "zip 2.4.2",
] ]
@@ -5966,6 +5980,61 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.2",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash 2.1.2",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -5993,6 +6062,8 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [ dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4", "rand_core 0.6.4",
"serde", "serde",
] ]
@@ -6033,6 +6104,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom 0.2.17",
"serde", "serde",
] ]
@@ -6203,6 +6275,44 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -6425,6 +6535,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -6773,6 +6884,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.21.0" version = "3.21.0"
@@ -7421,7 +7544,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -7610,7 +7733,7 @@ dependencies = [
"minisign-verify", "minisign-verify",
"osakit", "osakit",
"percent-encoding", "percent-encoding",
"reqwest", "reqwest 0.13.4",
"rustls", "rustls",
"semver", "semver",
"serde", "serde",
@@ -9149,6 +9272,15 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
+6 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "margin-app" name = "margin-app"
version = "0.1.2" version = "0.1.7"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -27,6 +27,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22" base64 = "0.22"
zip = "2" zip = "2"
fontdb = "0.23"
typst = "0.14.2" typst = "0.14.2"
typst-pdf = "0.14.2" typst-pdf = "0.14.2"
typst-as-lib = "0.15.5" typst-as-lib = "0.15.5"
@@ -35,6 +36,10 @@ spellbook = "0.4"
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in, # bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
# so bump deliberately and re-audit the stubs. (see [patch.crates-io]) # so bump deliberately and re-audit the stubs. (see [patch.crates-io])
harper-core = { version = "=2.5.0", features = ["concurrent"] } harper-core = { version = "=2.5.0", features = ["concurrent"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
sha2 = "0.10"
rand = "0.8"
url = "2"
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend). # harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm # Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
+1
View File
@@ -5,6 +5,7 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-destroy",
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"updater:default", "updater:default",
-11
View File
@@ -5,9 +5,6 @@ use std::io::{Read, Write};
use zip::write::SimpleFileOptions; use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter}; use zip::{CompressionMethod, ZipArchive, ZipWriter};
static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct EpubFile { pub struct EpubFile {
path: String, path: String,
@@ -48,14 +45,6 @@ fn build(files: &[EpubFile]) -> Result<Vec<u8>, String> {
zip.write_all(&bytes).map_err(|e| e.to_string())?; zip.write_all(&bytes).map_err(|e| e.to_string())?;
} }
for (path, bytes) in [
("OEBPS/fonts/Literata-VF.ttf", LITERATA),
("OEBPS/fonts/Literata-Italic-VF.ttf", LITERATA_ITALIC),
] {
zip.start_file(path, deflated).map_err(|e| e.to_string())?;
zip.write_all(bytes).map_err(|e| e.to_string())?;
}
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner()) Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
} }
+47
View File
@@ -0,0 +1,47 @@
use fontdb::{Database, Family, Query};
fn system_db() -> Database {
let mut db = Database::new();
db.load_system_fonts();
db
}
#[tauri::command(async)]
pub fn list_system_fonts() -> Vec<String> {
let db = system_db();
let mut names: Vec<String> = db
.faces()
.filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
.collect();
names.sort();
names.dedup();
names
}
pub fn system_font_bytes(family: &str) -> Vec<Vec<u8>> {
let db = system_db();
let mut out = Vec::new();
let styles = [
(fontdb::Weight::NORMAL, fontdb::Style::Normal),
(fontdb::Weight::NORMAL, fontdb::Style::Italic),
(fontdb::Weight::BOLD, fontdb::Style::Normal),
(fontdb::Weight::BOLD, fontdb::Style::Italic),
];
let mut seen = std::collections::HashSet::new();
for (weight, style) in styles {
let query = Query {
families: &[Family::Name(family)],
weight,
style,
..Query::default()
};
if let Some(id) = db.query(&query) {
if seen.insert(id) {
if let Some(bytes) = db.with_face_data(id, |data, _index| data.to_vec()) {
out.push(bytes);
}
}
}
}
out
}
+806
View File
@@ -0,0 +1,806 @@
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use rand::RngCore;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{Emitter, Manager};
const FOLDER_NAME: &str = "margin";
const DICTIONARY_NAME: &str = "custom-dictionary.txt";
const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
const AUTH_TIMEOUT_SECS: u64 = 120;
const CREDENTIALS_JSON: &str =
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../google-credentials.json"));
static HTTP: LazyLock<reqwest::Client> = LazyLock::new(reqwest::Client::new);
#[derive(Deserialize)]
struct CredentialsFile {
installed: Credentials,
}
#[derive(Deserialize)]
struct Credentials {
client_id: String,
client_secret: String,
auth_uri: String,
token_uri: String,
}
fn load_credentials() -> Result<Credentials, String> {
let runtime_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../google-credentials.json");
let raw = fs::read_to_string(runtime_path).unwrap_or_else(|_| CREDENTIALS_JSON.to_string());
let parsed: CredentialsFile = serde_json::from_str(&raw)
.map_err(|e| format!("invalid google-credentials.json: {e}"))?;
let creds = parsed.installed;
if creds.client_id.starts_with("YOUR_CLIENT_ID") || creds.client_secret.starts_with("YOUR_CLIENT_SECRET") {
return Err("Google Drive is not set up yet. Add a real OAuth desktop client to google-credentials.json.".to_string());
}
Ok(creds)
}
#[derive(Default)]
pub struct GDriveState(pub Mutex<Session>);
#[derive(Default)]
pub struct Session {
refresh_token: Option<String>,
access_token: Option<String>,
access_expiry: u64,
email: Option<String>,
folder_id: Option<String>,
}
#[derive(Serialize, Deserialize, Default)]
struct BackupState {
#[serde(default)]
refresh_token: Option<String>,
email: Option<String>,
folder_id: Option<String>,
last_backup: Option<u64>,
#[serde(default)]
files: HashMap<String, FileRecord>,
}
#[derive(Serialize, Deserialize, Clone)]
struct FileRecord {
hash: String,
drive_id: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Status {
connected: bool,
email: Option<String>,
last_backup: Option<u64>,
pending: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RestoreResult {
restored: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupOutcome {
uploaded: usize,
#[serde(flatten)]
status: Status,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteBackup {
name: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AuthEvent {
ok: bool,
error: Option<String>,
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
}
fn dictionary_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(crate::library::app_data_dir(app)?.join(DICTIONARY_NAME))
}
fn state_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(crate::library::app_data_dir(app)?.join("backup.json"))
}
fn load_state(app: &tauri::AppHandle) -> BackupState {
let path = match state_path(app) {
Ok(p) => p,
Err(_) => return BackupState::default(),
};
match fs::read_to_string(&path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => BackupState::default(),
}
}
fn save_state(app: &tauri::AppHandle, state: &BackupState) -> Result<(), String> {
let path = state_path(app)?;
let contents = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
crate::project::atomic_write(&path, contents.as_bytes(), false)
}
fn random_b64(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
URL_SAFE_NO_PAD.encode(buf)
}
fn pkce_challenge(verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(hasher.finalize())
}
fn hash_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn urlencode(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}
fn drive_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('\'', "\\'")
}
fn safe_book_name(name: &str) -> bool {
match name.strip_suffix(".margin") {
Some(stem) => !stem.is_empty() && stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
None => false,
}
}
fn write_http_message(stream: &mut TcpStream, message: &str) {
let body = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>margin</title></head>\
<body style=\"font-family:system-ui,sans-serif;text-align:center;padding-top:80px;color:#222\">\
<h2>{message}</h2></body></html>"
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
fn await_code(listener: TcpListener, expected_state: &str, deadline: Instant) -> Result<String, String> {
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
loop {
if Instant::now() > deadline {
return Err("Timed out waiting for Google authorization.".to_string());
}
match listener.accept() {
Ok((mut stream, _)) => {
stream.set_nonblocking(false).ok();
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
let mut buf = [0u8; 8192];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]);
let path = request.lines().next().and_then(|line| line.split_whitespace().nth(1)).unwrap_or("");
if path == "/favicon.ico" {
write_http_message(&mut stream, "margin");
continue;
}
let full = format!("http://127.0.0.1{path}");
let parsed = match url::Url::parse(&full) {
Ok(p) => p,
Err(_) => {
write_http_message(&mut stream, "Waiting for Google…");
continue;
}
};
let mut code = None;
let mut state = None;
let mut error = None;
for (key, value) in parsed.query_pairs() {
match key.as_ref() {
"code" => code = Some(value.into_owned()),
"state" => state = Some(value.into_owned()),
"error" => error = Some(value.into_owned()),
_ => {}
}
}
if let Some(error) = error {
write_http_message(&mut stream, "Authorization was cancelled. You can close this tab.");
return Err(format!("Google authorization failed: {error}"));
}
match (code, state) {
(Some(code), Some(state)) if state == expected_state => {
write_http_message(&mut stream, "Connected to margin. You can close this tab.");
return Ok(code);
}
(Some(_), _) => {
write_http_message(&mut stream, "Could not verify the request. You can close this tab.");
return Err("State mismatch during Google authorization.".to_string());
}
_ => {
write_http_message(&mut stream, "Waiting for Google…");
}
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(150));
}
Err(e) => return Err(e.to_string()),
}
}
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: u64,
}
#[derive(Deserialize)]
struct UserInfo {
#[serde(default)]
email: Option<String>,
}
#[derive(Deserialize)]
struct DriveFile {
id: String,
#[serde(default)]
name: String,
}
#[derive(Deserialize)]
struct FileList {
#[serde(default)]
files: Vec<DriveFile>,
#[serde(default, rename = "nextPageToken")]
next_page_token: Option<String>,
}
async fn read_json<T: DeserializeOwned>(resp: reqwest::Response, context: &str) -> Result<T, String> {
let status = resp.status();
let text = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!("{context} failed ({status}): {text}"));
}
serde_json::from_str(&text).map_err(|e| format!("{context}: could not parse response: {e}"))
}
async fn exchange_code(creds: &Credentials, code: &str, redirect: &str, verifier: &str) -> Result<TokenResponse, String> {
let resp = HTTP
.post(&creds.token_uri)
.form(&[
("client_id", creds.client_id.as_str()),
("client_secret", creds.client_secret.as_str()),
("code", code),
("code_verifier", verifier),
("grant_type", "authorization_code"),
("redirect_uri", redirect),
])
.send()
.await
.map_err(|e| e.to_string())?;
read_json(resp, "Google token exchange").await
}
async fn refresh_access_token(creds: &Credentials, refresh_token: &str) -> Result<TokenResponse, String> {
let resp = HTTP
.post(&creds.token_uri)
.form(&[
("client_id", creds.client_id.as_str()),
("client_secret", creds.client_secret.as_str()),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
])
.send()
.await
.map_err(|e| e.to_string())?;
read_json(resp, "Google token refresh").await
}
async fn fetch_email(access_token: &str) -> Result<String, String> {
let resp = HTTP
.get("https://www.googleapis.com/oauth2/v2/userinfo")
.bearer_auth(access_token)
.send()
.await
.map_err(|e| e.to_string())?;
let info: UserInfo = read_json(resp, "Google account lookup").await?;
Ok(info.email.unwrap_or_default())
}
async fn ensure_folder(access_token: &str) -> Result<String, String> {
let query = format!(
"name = '{}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false",
drive_escape(FOLDER_NAME)
);
let resp = HTTP
.get("https://www.googleapis.com/drive/v3/files")
.bearer_auth(access_token)
.query(&[
("q", query.as_str()),
("fields", "files(id,name)"),
("spaces", "drive"),
("pageSize", "1"),
])
.send()
.await
.map_err(|e| e.to_string())?;
let list: FileList = read_json(resp, "Drive folder lookup").await?;
if let Some(folder) = list.files.into_iter().next() {
return Ok(folder.id);
}
let body = serde_json::json!({
"name": FOLDER_NAME,
"mimeType": "application/vnd.google-apps.folder",
});
let resp = HTTP
.post("https://www.googleapis.com/drive/v3/files")
.bearer_auth(access_token)
.query(&[("fields", "id")])
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
let created: DriveFile = read_json(resp, "Drive folder creation").await?;
Ok(created.id)
}
async fn find_file(access_token: &str, folder_id: &str, name: &str) -> Result<Option<DriveFile>, String> {
let query = format!(
"'{}' in parents and name = '{}' and trashed = false",
drive_escape(folder_id),
drive_escape(name)
);
let resp = HTTP
.get("https://www.googleapis.com/drive/v3/files")
.bearer_auth(access_token)
.query(&[
("q", query.as_str()),
("fields", "files(id,name)"),
("spaces", "drive"),
("pageSize", "1"),
])
.send()
.await
.map_err(|e| e.to_string())?;
let list: FileList = read_json(resp, "Drive file lookup").await?;
Ok(list.files.into_iter().next())
}
async fn list_in_folder(access_token: &str, folder_id: &str) -> Result<Vec<DriveFile>, String> {
let query = format!("'{}' in parents and trashed = false", drive_escape(folder_id));
let mut files = Vec::new();
let mut page_token: Option<String> = None;
loop {
let mut request = HTTP
.get("https://www.googleapis.com/drive/v3/files")
.bearer_auth(access_token)
.query(&[
("q", query.as_str()),
("fields", "nextPageToken,files(id,name)"),
("spaces", "drive"),
("pageSize", "100"),
]);
if let Some(token) = &page_token {
request = request.query(&[("pageToken", token.as_str())]);
}
let resp = request.send().await.map_err(|e| e.to_string())?;
let FileList { files: page, next_page_token } = read_json(resp, "Drive file list").await?;
files.extend(page);
match next_page_token {
Some(token) => page_token = Some(token),
None => break,
}
}
Ok(files)
}
async fn upload_file(
access_token: &str,
folder_id: &str,
name: &str,
bytes: &[u8],
existing_id: Option<String>,
) -> Result<DriveFile, String> {
let boundary = "margin7f3e2a1b9c8d";
let metadata = match &existing_id {
Some(_) => serde_json::json!({ "name": name }),
None => serde_json::json!({ "name": name, "parents": [folder_id] }),
};
let mut body: Vec<u8> = Vec::new();
body.extend_from_slice(format!("--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n").as_bytes());
body.extend_from_slice(serde_json::to_string(&metadata).unwrap_or_default().as_bytes());
body.extend_from_slice(format!("\r\n--{boundary}\r\nContent-Type: application/octet-stream\r\n\r\n").as_bytes());
body.extend_from_slice(bytes);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
let request = match &existing_id {
Some(id) => HTTP.patch(format!("https://www.googleapis.com/upload/drive/v3/files/{id}")),
None => HTTP.post("https://www.googleapis.com/upload/drive/v3/files"),
};
let resp = request
.bearer_auth(access_token)
.query(&[("uploadType", "multipart"), ("fields", "id")])
.header(reqwest::header::CONTENT_TYPE, format!("multipart/related; boundary={boundary}"))
.body(body)
.send()
.await
.map_err(|e| e.to_string())?;
read_json(resp, &format!("Drive upload of {name}")).await
}
async fn download_file(access_token: &str, id: &str) -> Result<Vec<u8>, String> {
let resp = HTTP
.get(format!("https://www.googleapis.com/drive/v3/files/{id}"))
.bearer_auth(access_token)
.query(&[("alt", "media")])
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("Drive download failed ({status}): {text}"));
}
Ok(resp.bytes().await.map_err(|e| e.to_string())?.to_vec())
}
async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Result<String, String> {
let (access, expiry, refresh) = {
let session = state.0.lock().unwrap();
(session.access_token.clone(), session.access_expiry, session.refresh_token.clone())
};
if let Some(token) = access {
if now() < expiry {
return Ok(token);
}
}
let refresh = refresh.ok_or("Not connected to Google Drive.")?;
let creds = load_credentials()?;
let tokens = refresh_access_token(&creds, &refresh).await?;
let access_token = tokens.access_token.clone();
{
let mut session = state.0.lock().unwrap();
session.access_token = Some(tokens.access_token);
session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
if let Some(rotated) = &tokens.refresh_token {
session.refresh_token = Some(rotated.clone());
}
}
if let Some(rotated) = &tokens.refresh_token {
let mut stored = load_state(app);
stored.refresh_token = Some(rotated.clone());
let _ = save_state(app, &stored);
}
Ok(access_token)
}
async fn ensure_folder_id(app: &tauri::AppHandle, state: &GDriveState, access_token: &str) -> Result<String, String> {
{
let session = state.0.lock().unwrap();
if let Some(folder) = &session.folder_id {
return Ok(folder.clone());
}
}
let folder_id = ensure_folder(access_token).await?;
{
let mut session = state.0.lock().unwrap();
session.folder_id = Some(folder_id.clone());
}
let mut stored = load_state(app);
stored.folder_id = Some(folder_id.clone());
save_state(app, &stored)?;
Ok(folder_id)
}
fn collect_local_files(app: &tauri::AppHandle) -> Result<Vec<(String, Vec<u8>)>, String> {
let mut files = Vec::new();
let library = crate::library::library_dir(app)?;
for entry in fs::read_dir(&library).map_err(|e| e.to_string())? {
let path = match entry {
Ok(entry) => entry.path(),
Err(_) => continue,
};
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) if safe_book_name(name) => name.to_string(),
_ => continue,
};
let bytes = fs::read(&path).map_err(|e| e.to_string())?;
files.push((name, bytes));
}
let dictionary = dictionary_path(app)?;
if dictionary.exists() {
let bytes = fs::read(&dictionary).map_err(|e| e.to_string())?;
files.push((DICTIONARY_NAME.to_string(), bytes));
}
Ok(files)
}
fn file_pending(path: &std::path::Path, name: &str, stored: &BackupState, last_backup: u64) -> bool {
let record = match stored.files.get(name) {
Some(record) => record,
None => return true,
};
let touched = fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|since| since.as_secs() > last_backup)
.unwrap_or(true);
if !touched {
return false;
}
match fs::read(path) {
Ok(bytes) => record.hash != hash_bytes(&bytes),
Err(_) => false,
}
}
fn compute_pending(app: &tauri::AppHandle, stored: &BackupState) -> bool {
let last_backup = stored.last_backup.unwrap_or(0);
let library = match crate::library::library_dir(app) {
Ok(dir) => dir,
Err(_) => return false,
};
if let Ok(entries) = fs::read_dir(&library) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if safe_book_name(name) && file_pending(&path, name, stored, last_backup) {
return true;
}
}
}
}
match dictionary_path(app) {
Ok(dict) if dict.exists() => file_pending(&dict, DICTIONARY_NAME, stored, last_backup),
_ => false,
}
}
fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status {
let (connected, email) = {
let session = state.0.lock().unwrap();
(session.refresh_token.is_some(), session.email.clone())
};
let stored = load_state(app);
let pending = connected && compute_pending(app, &stored);
Status {
connected,
email: email.or(stored.email),
last_backup: stored.last_backup,
pending,
}
}
pub fn init_session(app: &tauri::AppHandle) {
let state = app.state::<GDriveState>();
let stored = load_state(app);
let mut session = state.0.lock().unwrap();
session.refresh_token = stored.refresh_token;
session.email = stored.email;
session.folder_id = stored.folder_id;
}
async fn complete_auth(
app: &tauri::AppHandle,
listener: TcpListener,
csrf: String,
verifier: String,
redirect: String,
creds: Credentials,
) -> Result<(), String> {
let code = tauri::async_runtime::spawn_blocking(move || {
let deadline = Instant::now() + Duration::from_secs(AUTH_TIMEOUT_SECS);
await_code(listener, &csrf, deadline)
})
.await
.map_err(|e| e.to_string())??;
let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?;
let email = fetch_email(&tokens.access_token).await?;
let folder_id = ensure_folder(&tokens.access_token).await?;
{
let state = app.state::<GDriveState>();
let mut session = state.0.lock().unwrap();
if let Some(refresh) = &tokens.refresh_token {
session.refresh_token = Some(refresh.clone());
}
session.access_token = Some(tokens.access_token.clone());
session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
session.email = Some(email.clone());
session.folder_id = Some(folder_id.clone());
}
let mut stored = load_state(app);
if let Some(refresh) = &tokens.refresh_token {
stored.refresh_token = Some(refresh.clone());
}
stored.email = Some(email);
stored.folder_id = Some(folder_id);
save_state(app, &stored)?;
Ok(())
}
#[tauri::command]
pub async fn gdrive_connect(app: tauri::AppHandle) -> Result<String, String> {
let creds = load_credentials()?;
let verifier = random_b64(64);
let challenge = pkce_challenge(&verifier);
let csrf = random_b64(24);
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let redirect = format!("http://127.0.0.1:{port}");
let auth_url = format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&code_challenge={}&code_challenge_method=S256&state={}&access_type=offline&prompt=consent",
creds.auth_uri,
urlencode(&creds.client_id),
urlencode(&redirect),
urlencode(SCOPES),
challenge,
urlencode(&csrf),
);
use tauri_plugin_opener::OpenerExt;
let _ = app.opener().open_url(auth_url.clone(), None::<&str>);
let app_bg = app.clone();
tauri::async_runtime::spawn(async move {
let event = match complete_auth(&app_bg, listener, csrf, verifier, redirect, creds).await {
Ok(()) => AuthEvent { ok: true, error: None },
Err(error) => AuthEvent { ok: false, error: Some(error) },
};
let _ = app_bg.emit("gdrive-auth", event);
});
Ok(auth_url)
}
#[tauri::command]
pub async fn gdrive_disconnect(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<Status, String> {
let refresh = {
let session = state.0.lock().unwrap();
session.refresh_token.clone()
};
if let Some(refresh) = refresh {
let _ = HTTP
.post("https://oauth2.googleapis.com/revoke")
.form(&[("token", refresh.as_str())])
.send()
.await;
}
{
let mut session = state.0.lock().unwrap();
session.refresh_token = None;
session.access_token = None;
session.access_expiry = 0;
session.email = None;
}
let mut stored = load_state(&app);
stored.refresh_token = None;
stored.email = None;
save_state(&app, &stored)?;
Ok(status_inner(&app, &state))
}
#[tauri::command]
pub async fn gdrive_status(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<Status, String> {
Ok(status_inner(&app, &state))
}
#[tauri::command]
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
let access_token = valid_access_token(&app, &state).await?;
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
let files = collect_local_files(&app)?;
let mut stored = load_state(&app);
let mut uploaded = 0;
for (name, bytes) in &files {
let hash = hash_bytes(bytes);
let existing = stored.files.get(name).cloned();
if let Some(record) = &existing {
if record.hash == hash {
continue;
}
}
let drive_id = match &existing {
Some(record) => Some(record.drive_id.clone()),
None => find_file(&access_token, &folder_id, name).await?.map(|file| file.id),
};
let result = upload_file(&access_token, &folder_id, name, bytes, drive_id).await?;
stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id });
uploaded += 1;
}
if uploaded > 0 {
stored.last_backup = Some(now());
save_state(&app, &stored)?;
}
Ok(BackupOutcome {
uploaded,
status: Status {
connected: true,
email: stored.email.clone(),
last_backup: stored.last_backup,
pending: false,
},
})
}
#[tauri::command]
pub async fn gdrive_restore(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<RestoreResult, String> {
let access_token = valid_access_token(&app, &state).await?;
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
let remote = list_in_folder(&access_token, &folder_id).await?;
let mut stored = load_state(&app);
let mut restored = 0;
for file in &remote {
let is_dictionary = file.name == DICTIONARY_NAME;
let is_book = safe_book_name(&file.name);
if !is_dictionary && !is_book {
continue;
}
let bytes = download_file(&access_token, &file.id).await?;
let destination = if is_dictionary {
dictionary_path(&app)?
} else {
crate::library::library_dir(&app)?.join(&file.name)
};
crate::project::atomic_write(&destination, &bytes, false)?;
stored.files.insert(
file.name.clone(),
FileRecord { hash: hash_bytes(&bytes), drive_id: file.id.clone() },
);
restored += 1;
}
stored.last_backup = Some(now());
save_state(&app, &stored)?;
Ok(RestoreResult { restored })
}
#[tauri::command]
pub async fn gdrive_list_backups(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<Vec<RemoteBackup>, String> {
let access_token = valid_access_token(&app, &state).await?;
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
let remote = list_in_folder(&access_token, &folder_id).await?;
Ok(remote
.into_iter()
.filter(|file| safe_book_name(&file.name))
.map(|file| RemoteBackup { name: file.name })
.collect())
}
+15 -1
View File
@@ -1,4 +1,6 @@
mod epub; mod epub;
mod fonts;
mod gdrive;
mod library; mod library;
mod pdf; mod pdf;
mod project; mod project;
@@ -71,6 +73,11 @@ pub fn run() {
builder builder
.manage(proofing::new_state()) .manage(proofing::new_state())
.manage(gdrive::GDriveState::default())
.setup(|app| {
gdrive::init_session(app.handle());
Ok(())
})
.menu(|handle| build_menu(handle)) .menu(|handle| build_menu(handle))
.on_menu_event(|app, event| { .on_menu_event(|app, event| {
if matches!( if matches!(
@@ -83,6 +90,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
epub::package_epub, epub::package_epub,
epub::unzip_epub, epub::unzip_epub,
fonts::list_system_fonts,
pdf::compile_pdf, pdf::compile_pdf,
project::read_file, project::read_file,
project::write_file, project::write_file,
@@ -92,7 +100,13 @@ pub fn run() {
library::save_book, library::save_book,
library::delete_book, library::delete_book,
proofing::proof_text, proofing::proof_text,
proofing::remember_word proofing::remember_word,
gdrive::gdrive_connect,
gdrive::gdrive_disconnect,
gdrive::gdrive_status,
gdrive::gdrive_backup,
gdrive::gdrive_restore,
gdrive::gdrive_list_backups
]) ])
.run(context) .run(context)
.expect("error while running margin"); .expect("error while running margin");
+40 -12
View File
@@ -7,18 +7,37 @@ pub struct BookSummary {
id: String, id: String,
title: String, title: String,
author: String, author: String,
corrupt: bool,
} }
fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> { fn corrupt_summary(stem: &str) -> BookSummary {
let dir = app BookSummary {
.path() id: stem.to_string(),
.app_data_dir() title: "Unreadable book".to_string(),
.map_err(|e| e.to_string())? author: String::new(),
.join("library"); corrupt: true,
}
}
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?; fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir) Ok(dir)
} }
pub(crate) fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app_data_dir(app)?.join("library");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
fn book_path(app: &tauri::AppHandle, id: &str) -> Result<PathBuf, String> {
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err("invalid book id".to_string());
}
Ok(library_dir(app)?.join(format!("{id}.margin")))
}
#[tauri::command] #[tauri::command]
pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> { pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
let dir = library_dir(&app)?; let dir = library_dir(&app)?;
@@ -31,16 +50,24 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
if path.extension().and_then(|e| e.to_str()) != Some("margin") { if path.extension().and_then(|e| e.to_str()) != Some("margin") {
continue; continue;
} }
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let contents = match fs::read_to_string(&path) { let contents = match fs::read_to_string(&path) {
Ok(contents) => contents, Ok(contents) => contents,
Err(_) => continue, Err(_) => {
books.push(corrupt_summary(stem));
continue;
}
}; };
let value: serde_json::Value = match serde_json::from_str(&contents) { let value: serde_json::Value = match serde_json::from_str(&contents) {
Ok(value) => value, Ok(value) => value,
Err(_) => continue, Err(_) => {
books.push(corrupt_summary(stem));
continue;
}
}; };
let id = value.get("id").and_then(|v| v.as_str()).unwrap_or(""); let id = value.get("id").and_then(|v| v.as_str()).unwrap_or("");
if id.is_empty() { if id.is_empty() {
books.push(corrupt_summary(stem));
continue; continue;
} }
let metadata = value.get("metadata"); let metadata = value.get("metadata");
@@ -58,6 +85,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
id: id.to_string(), id: id.to_string(),
title, title,
author, author,
corrupt: false,
}); });
} }
Ok(books) Ok(books)
@@ -65,19 +93,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
#[tauri::command] #[tauri::command]
pub fn load_book(app: tauri::AppHandle, id: String) -> Result<String, String> { pub fn load_book(app: tauri::AppHandle, id: String) -> Result<String, String> {
let path = library_dir(&app)?.join(format!("{id}.margin")); let path = book_path(&app, &id)?;
fs::read_to_string(&path).map_err(|e| e.to_string()) fs::read_to_string(&path).map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> { pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> {
let path = library_dir(&app)?.join(format!("{id}.margin")); let path = book_path(&app, &id)?;
fs::write(&path, contents).map_err(|e| e.to_string()) crate::project::atomic_write(&path, contents.as_bytes(), true)
} }
#[tauri::command] #[tauri::command]
pub fn delete_book(app: tauri::AppHandle, id: String) -> Result<(), String> { pub fn delete_book(app: tauri::AppHandle, id: String) -> Result<(), String> {
let path = library_dir(&app)?.join(format!("{id}.margin")); let path = book_path(&app, &id)?;
if path.exists() { if path.exists() {
fs::remove_file(&path).map_err(|e| e.to_string())?; fs::remove_file(&path).map_err(|e| e.to_string())?;
} }
+70 -8
View File
@@ -1,7 +1,8 @@
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use serde::Deserialize; use serde::Deserialize;
use typst::diag::{Severity, SourceDiagnostic}; use tauri::Emitter;
use typst::diag::{Severity, SourceDiagnostic, Warned};
use typst::layout::PagedDocument; use typst::layout::PagedDocument;
use typst_as_lib::TypstEngine; use typst_as_lib::TypstEngine;
@@ -9,6 +10,29 @@ static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf"); static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
static HANKEN: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-VF.ttf"); static HANKEN: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-VF.ttf");
static HANKEN_ITALIC: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-Italic-VF.ttf"); static HANKEN_ITALIC: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-Italic-VF.ttf");
static EB_GARAMOND: &[u8] = include_bytes!("../../public/fonts/EBGaramond-VF.ttf");
static EB_GARAMOND_ITALIC: &[u8] = include_bytes!("../../public/fonts/EBGaramond-Italic-VF.ttf");
static LORA: &[u8] = include_bytes!("../../public/fonts/Lora-VF.ttf");
static LORA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Lora-Italic-VF.ttf");
static SOURCE_SERIF: &[u8] = include_bytes!("../../public/fonts/SourceSerif4-VF.ttf");
static SOURCE_SERIF_ITALIC: &[u8] = include_bytes!("../../public/fonts/SourceSerif4-Italic-VF.ttf");
static FRAUNCES: &[u8] = include_bytes!("../../public/fonts/Fraunces-VF.ttf");
static FRAUNCES_ITALIC: &[u8] = include_bytes!("../../public/fonts/Fraunces-Italic-VF.ttf");
static EB_GARAMOND_FONTS: [&[u8]; 2] = [EB_GARAMOND, EB_GARAMOND_ITALIC];
static LORA_FONTS: [&[u8]; 2] = [LORA, LORA_ITALIC];
static SOURCE_SERIF_FONTS: [&[u8]; 2] = [SOURCE_SERIF, SOURCE_SERIF_ITALIC];
static FRAUNCES_FONTS: [&[u8]; 2] = [FRAUNCES, FRAUNCES_ITALIC];
fn bundled_font_bytes(id: &str) -> &'static [&'static [u8]] {
match id {
"eb-garamond" => &EB_GARAMOND_FONTS,
"lora" => &LORA_FONTS,
"source-serif" => &SOURCE_SERIF_FONTS,
"fraunces" => &FRAUNCES_FONTS,
_ => &[],
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ImageInput { pub struct ImageInput {
@@ -16,7 +40,12 @@ pub struct ImageInput {
data: String, data: String,
} }
fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> { fn compile(
source: String,
images: &[ImageInput],
bundled_fonts: &[String],
system_fonts: &[String],
) -> Result<(Vec<u8>, String), String> {
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len()); let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
for image in images { for image in images {
let bytes = STANDARD let bytes = STANDARD
@@ -25,20 +54,53 @@ fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
binaries.push((image.path.as_str(), bytes)); binaries.push((image.path.as_str(), bytes));
} }
let mut fonts: Vec<Vec<u8>> = vec![
LITERATA.to_vec(),
LITERATA_ITALIC.to_vec(),
HANKEN.to_vec(),
HANKEN_ITALIC.to_vec(),
];
for id in bundled_fonts {
for bytes in bundled_font_bytes(id) {
fonts.push(bytes.to_vec());
}
}
for family in system_fonts {
fonts.extend(crate::fonts::system_font_bytes(family));
}
let engine = TypstEngine::builder() let engine = TypstEngine::builder()
.main_file(source) .main_file(source)
.fonts([LITERATA, LITERATA_ITALIC, HANKEN, HANKEN_ITALIC]) .fonts(fonts)
.with_static_file_resolver(binaries) .with_static_file_resolver(binaries)
.build(); .build();
let document: PagedDocument = engine.compile().output.map_err(|e| format_diagnostics(&e))?; let Warned { output, warnings } = engine.compile();
let document: PagedDocument = output.map_err(|e| format_diagnostics(&e))?;
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d)) let bytes =
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))?;
let warning_text = if warnings.is_empty() {
String::new()
} else {
format_source_diagnostics(&warnings)
};
Ok((bytes, warning_text))
} }
#[tauri::command(async)] #[tauri::command(async)]
pub fn compile_pdf(source: String, images: Vec<ImageInput>) -> Result<tauri::ipc::Response, String> { pub fn compile_pdf(
compile(source, &images).map(tauri::ipc::Response::new) app: tauri::AppHandle,
source: String,
images: Vec<ImageInput>,
emit_warnings: bool,
bundled_fonts: Vec<String>,
system_fonts: Vec<String>,
) -> Result<tauri::ipc::Response, String> {
let (bytes, warnings) = compile(source, &images, &bundled_fonts, &system_fonts)?;
if emit_warnings && !warnings.is_empty() {
app.emit("pdf-warnings", warnings).ok();
}
Ok(tauri::ipc::Response::new(bytes))
} }
fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String { fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String {
+30 -3
View File
@@ -1,6 +1,33 @@
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use std::fs; use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(suffix);
PathBuf::from(name)
}
pub fn atomic_write(path: &Path, bytes: &[u8], backup: bool) -> Result<(), String> {
let tmp = with_suffix(path, ".tmp");
{
let mut f = File::create(&tmp).map_err(|e| e.to_string())?;
f.write_all(bytes).map_err(|e| e.to_string())?;
f.sync_all().map_err(|e| e.to_string())?;
}
if path.exists() {
if backup {
let bak = with_suffix(path, ".bak");
let _ = fs::remove_file(&bak);
fs::rename(path, &bak).map_err(|e| e.to_string())?;
} else {
fs::remove_file(path).map_err(|e| e.to_string())?;
}
}
fs::rename(&tmp, path).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub fn read_file(path: String) -> Result<String, String> { pub fn read_file(path: String) -> Result<String, String> {
@@ -9,11 +36,11 @@ pub fn read_file(path: String) -> Result<String, String> {
#[tauri::command] #[tauri::command]
pub fn write_file(path: String, contents: String) -> Result<(), String> { pub fn write_file(path: String, contents: String) -> Result<(), String> {
fs::write(&path, contents).map_err(|e| e.to_string()) atomic_write(Path::new(&path), contents.as_bytes(), true)
} }
#[tauri::command] #[tauri::command]
pub fn write_bytes(path: String, data: String) -> Result<(), String> { pub fn write_bytes(path: String, data: String) -> Result<(), String> {
let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_string())?; let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_string())?;
fs::write(&path, bytes).map_err(|e| e.to_string()) atomic_write(Path::new(&path), &bytes, false)
} }
+6 -2
View File
@@ -176,12 +176,13 @@ fn collect_grammar(harper: &mut Harper, text: &str, chars: &[char], issues: &mut
#[tauri::command] #[tauri::command]
pub async fn proof_text( pub async fn proof_text(
app: tauri::AppHandle, app: tauri::AppHandle,
state: tauri::State<'_, ProofState>,
text: String, text: String,
spelling: bool, spelling: bool,
grammar: bool, grammar: bool,
) -> Result<Vec<Issue>, String> { ) -> Result<Vec<Issue>, String> {
let mut guard = state.inner().lock().map_err(|e| e.to_string())?; tauri::async_runtime::spawn_blocking(move || -> Result<Vec<Issue>, String> {
let state = app.state::<ProofState>();
let mut guard = state.lock().map_err(|e| e.to_string())?;
if guard.is_none() { if guard.is_none() {
*guard = Some(Engine { *guard = Some(Engine {
speller: None, speller: None,
@@ -206,6 +207,9 @@ pub async fn proof_text(
collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues); collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues);
} }
Ok(issues) Ok(issues)
})
.await
.map_err(|e| e.to_string())?
} }
#[tauri::command] #[tauri::command]
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "margin", "productName": "Margin",
"version": "0.1.2", "version": "0.1.7",
"identifier": "studio.margin.app", "identifier": "studio.margin.app",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@@ -21,7 +21,7 @@
} }
], ],
"security": { "security": {
"csp": null "csp": "default-src 'self'; img-src 'self' data: blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; connect-src 'self' ipc: http://ipc.localhost"
} }
}, },
"bundle": { "bundle": {
+59 -3
View File
@@ -1,10 +1,13 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Library } from "./components/Library"; import { Library } from "./components/Library";
import { EditorView } from "./components/EditorView"; import { EditorView } from "./components/EditorView";
import { BackupSettings } from "./components/BackupSettings";
import { useBook } from "./store/useBook"; import { useBook } from "./store/useBook";
import { useBackup } from "./store/useBackup";
import { isDesktop } from "./ipc"; import { isDesktop } from "./ipc";
import { newBook } from "./library"; import { createAndOpenBook, saveBook } from "./library";
import { runExport } from "./export/run"; import { runExport } from "./export/run";
import { checkForUpdates } from "./updater"; import { checkForUpdates } from "./updater";
@@ -17,17 +20,70 @@ function App() {
checkForUpdates(true); checkForUpdates(true);
const unlisten = listen<string>("menu-action", (event) => { const unlisten = listen<string>("menu-action", (event) => {
const state = useBook.getState(); const state = useBook.getState();
if (event.payload === "new-book") state.openBook(newBook()); if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice);
else if (event.payload === "export-pdf") runExport("pdf"); else if (event.payload === "export-pdf") runExport("pdf");
else if (event.payload === "export-epub") runExport("epub"); else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false); else if (event.payload === "check-updates") checkForUpdates(false);
}); });
const unlistenWarn = listen<string>("pdf-warnings", (event) => {
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
});
return () => {
unlisten.then((stop) => stop());
unlistenWarn.then((stop) => stop());
};
}, []);
useEffect(() => {
if (!isDesktop) return;
const win = getCurrentWindow();
const unlisten = win.onCloseRequested(async (event) => {
const { book, dirty } = useBook.getState();
const { connected } = useBackup.getState();
if ((!book || !dirty) && !connected) return;
event.preventDefault();
if (book && dirty) await saveBook(book).catch(() => {});
if (connected) {
await Promise.race([
useBackup.getState().backup(true),
new Promise((resolve) => setTimeout(resolve, 8000)),
]);
}
win.destroy();
});
return () => { return () => {
unlisten.then((stop) => stop()); unlisten.then((stop) => stop());
}; };
}, []); }, []);
return book ? <EditorView /> : <Library onOpen={openBook} />; useEffect(() => {
if (!isDesktop) return;
const unlisten = listen<{ ok: boolean; error: string | null }>("gdrive-auth", (event) => {
useBackup.getState().handleAuthEvent(event.payload.ok, event.payload.error);
});
return () => {
unlisten.then((stop) => stop());
};
}, []);
useEffect(() => {
if (!isDesktop) return;
const tick = async () => {
await useBackup.getState().refresh();
const state = useBackup.getState();
if (state.connected && state.pending) state.backup(true);
};
tick();
const id = setInterval(tick, 15 * 60 * 1000);
return () => clearInterval(id);
}, []);
return (
<>
{book ? <EditorView /> : <Library onOpen={openBook} />}
{isDesktop && <BackupSettings />}
</>
);
} }
export default App; export default App;
+63
View File
@@ -0,0 +1,63 @@
import { invoke } from "@tauri-apps/api/core";
import { isDesktop } from "./ipc";
export interface BackupStatus {
connected: boolean;
email: string | null;
lastBackup: number | null;
pending: boolean;
}
export interface RestoreResult {
restored: number;
}
export interface BackupOutcome extends BackupStatus {
uploaded: number;
}
export interface RemoteBackup {
name: string;
}
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false };
export async function gdriveStatus(): Promise<BackupStatus> {
if (!isDesktop) return OFFLINE;
return invoke<BackupStatus>("gdrive_status");
}
export async function gdriveConnect(): Promise<string> {
return invoke<string>("gdrive_connect");
}
export async function gdriveDisconnect(): Promise<BackupStatus> {
return invoke<BackupStatus>("gdrive_disconnect");
}
export async function gdriveBackup(): Promise<BackupOutcome> {
return invoke<BackupOutcome>("gdrive_backup");
}
export async function gdriveRestore(): Promise<RestoreResult> {
return invoke<RestoreResult>("gdrive_restore");
}
export async function gdriveListBackups(): Promise<RemoteBackup[]> {
if (!isDesktop) return [];
return invoke<RemoteBackup[]>("gdrive_list_backups");
}
export function formatBackupTime(ts: number | null): string {
if (!ts) return "Not backed up yet";
const diff = Date.now() - ts * 1000;
const min = Math.floor(diff / 60000);
if (min < 1) return "Backed up just now";
if (min < 60) return `Backed up ${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `Backed up ${hr}h ago`;
const day = Math.floor(hr / 24);
if (day === 1) return "Backed up yesterday";
if (day < 7) return `Backed up ${day}d ago`;
return `Backed up ${new Date(ts * 1000).toLocaleDateString()}`;
}
+13
View File
@@ -0,0 +1,13 @@
import { type BookFonts, fontStack } from "./model/fonts";
export function applyBookFonts(fonts: BookFonts): void {
const root = document.documentElement;
root.style.setProperty("--font-book", fontStack(fonts.body));
root.style.setProperty("--font-heading", fontStack(fonts.heading));
}
export function resetBookFonts(): void {
const root = document.documentElement;
root.style.removeProperty("--font-book");
root.style.removeProperty("--font-heading");
}
+48
View File
@@ -0,0 +1,48 @@
import { Icon } from "./Icon";
import { useBackup } from "../store/useBackup";
import { formatBackupTime } from "../backup";
export function BackupButton() {
const connected = useBackup((s) => s.connected);
const pending = useBackup((s) => s.pending);
const phase = useBackup((s) => s.phase);
const lastBackup = useBackup((s) => s.lastBackup);
const openSettings = useBackup((s) => s.openSettings);
const busy = phase === "working" || phase === "connecting";
const error = phase === "error";
const state = busy ? "working" : error ? "error" : !connected ? "off" : pending ? "pending" : "synced";
const title =
phase === "connecting"
? "Waiting for Google authorization…"
: state === "working"
? "Backing up…"
: state === "error"
? "Backup needs attention"
: state === "off"
? "Connect Google Drive"
: state === "pending"
? "Changes not backed up"
: formatBackupTime(lastBackup);
const onClick = () => {
openSettings();
};
return (
<button className="icon-btn backup-btn" data-state={state} onClick={onClick} title={title}>
{busy ? (
<Icon size={18}>
<path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" />
</Icon>
) : (
<Icon size={18}>
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" />
{connected ? <path d="M12 16.5v-5M9.7 12.8l2.3-2.3 2.3 2.3" /> : null}
</Icon>
)}
</button>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useState } from "react";
import { Icon } from "./Icon";
import { ConfirmDialog } from "./ConfirmDialog";
import { useBackup } from "../store/useBackup";
import { formatBackupTime, gdriveListBackups } from "../backup";
export function BackupSettings() {
const open = useBackup((s) => s.settingsOpen);
const close = useBackup((s) => s.closeSettings);
const connected = useBackup((s) => s.connected);
const email = useBackup((s) => s.email);
const lastBackup = useBackup((s) => s.lastBackup);
const pending = useBackup((s) => s.pending);
const phase = useBackup((s) => s.phase);
const connect = useBackup((s) => s.connect);
const disconnect = useBackup((s) => s.disconnect);
const backup = useBackup((s) => s.backup);
const restore = useBackup((s) => s.restore);
const cancelConnect = useBackup((s) => s.cancelConnect);
const openAuthUrl = useBackup((s) => s.openAuthUrl);
const copyAuthUrl = useBackup((s) => s.copyAuthUrl);
const [remoteCount, setRemoteCount] = useState<number | null>(null);
const [confirmRestore, setConfirmRestore] = useState(false);
const working = phase === "working";
useEffect(() => {
if (!open || !connected) {
setRemoteCount(null);
return;
}
let active = true;
gdriveListBackups()
.then((list) => active && setRemoteCount(list.length))
.catch(() => active && setRemoteCount(null));
return () => {
active = false;
};
}, [open, connected, lastBackup]);
if (!open) return null;
return (
<>
<div className="overlay" onClick={close}>
<div className="panel" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>Backup &amp; Sync</h2>
<button className="icon-btn" onClick={close} title="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
{phase === "connecting" ? (
<div className="backup-connecting">
<div className="backup-waiting">
<Icon size={16}>
<path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" />
</Icon>
<span>Waiting for Google authorization…</span>
</div>
<p>Finish in the browser tab that opened. If it didn't open, reopen the link or copy it into a browser.</p>
<div className="backup-link-row">
<button className="backup-text-btn" onClick={cancelConnect}>
Stop linking
</button>
<div className="backup-links">
<button className="backup-text-btn" onClick={openAuthUrl}>
Open link again
</button>
<span className="backup-link-sep">·</span>
<button className="backup-text-btn" onClick={copyAuthUrl}>
Copy link
</button>
</div>
</div>
</div>
) : !connected ? (
<div className="backup-intro">
<p>
Connect Google Drive to keep a private backup of every book. margin only ever sees the files it
creates in a <strong>margin</strong> folder, never the rest of your Drive.
</p>
<button className="btn-primary" disabled={working} onClick={connect}>
{working ? "Connecting…" : "Connect Google Drive"}
</button>
</div>
) : (
<>
<div className="backup-account">
<div className="backup-account-info">
<span className="field-label">Connected account</span>
<span className="backup-email">{email || "Google Drive"}</span>
</div>
<button className="btn-ghost" disabled={working} onClick={disconnect}>
Disconnect
</button>
</div>
<div className="backup-status-row">
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
</div>
<div className="backup-actions">
<button className="btn-primary" disabled={working} onClick={() => backup()}>
{working ? "Working…" : "Back up now"}
</button>
<button
className="btn-ghost"
disabled={working || remoteCount === 0}
onClick={() => setConfirmRestore(true)}
>
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
</button>
</div>
<p className="backup-note">
Backs up automatically when you close margin, and every 15 minutes while there are changes.
</p>
</>
)}
</div>
</div>
</div>
{confirmRestore && (
<ConfirmDialog
title="Restore from Google Drive"
message={
<>
This replaces your local books with the copies in Google Drive. Any local changes that haven't been backed
up will be lost.
</>
}
confirmLabel="Restore"
onConfirm={() => {
setConfirmRestore(false);
restore();
}}
onClose={() => setConfirmRestore(false)}
/>
)}
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { CSSProperties, ReactNode } from "react";
import type { Device } from "../devices";
import type { Book } from "../model/book";
import { CoverArt } from "./CoverArt";
interface DeviceFrameProps {
device: Device;
eyebrow?: ReactNode;
title?: ReactNode;
html?: string;
cover?: Book;
}
function frameVars(device: Device): CSSProperties {
return {
"--dv-bg": device.bg,
"--dv-ink": device.ink,
"--dv-fs": device.fontSize,
"--dv-lh": device.lineHeight,
"--dv-mx": device.marginX,
"--dv-my": device.marginY,
"--dv-bezel": device.bezel,
"--dv-bezel-w": device.bezelWidth,
"--dv-radius": device.radius,
"--dv-aspect": device.aspect,
} as CSSProperties;
}
function StatusBar() {
return (
<div className="device-statusbar">
<span className="device-clock">9:41</span>
<span className="device-batt" />
</div>
);
}
export function DeviceFrame({ device, eyebrow, title, html, cover }: DeviceFrameProps) {
return (
<div className="device-stage">
<div className="device-frame" data-kind={device.kind} style={frameVars(device)}>
<div className="device-screen">
{device.kind === "tablet" && <StatusBar />}
<div className="device-content">
{cover ? (
<CoverArt book={cover} />
) : (
<>
<div className="device-opener">
{eyebrow && <div className="device-eyebrow">{eyebrow}</div>}
{title && <div className="device-title">{title}</div>}
</div>
<div className="device-body" dangerouslySetInnerHTML={{ __html: html ?? "" }} />
</>
)}
</div>
{device.kind === "eink" && (
<div className="device-footer">
<span className="device-foot-title">{cover ? device.label : title}</span>
<span className="device-batt" />
</div>
)}
</div>
</div>
</div>
);
}
+111 -39
View File
@@ -1,12 +1,15 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { generateHTML } from "@tiptap/core"; import { generateHTML } from "@tiptap/core";
import { COVER_ID, useBook } from "../store/useBook"; import { COVER_ID, useBook } from "../store/useBook";
import { type TrimSize, bodyNumber, chapterKind } from "../model/book"; import { type Chapter, type TrimSize, bodyNumber, chapterKind } from "../model/book";
import { editorExtensions } from "../editor/extensions"; import { editorExtensions } from "../editor/extensions";
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst"; import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
import { compilePdf, isDesktop } from "../ipc"; import { compilePdf, isDesktop } from "../ipc";
import { DEVICES, type Device, findDevice } from "../devices";
import { usePreviewMode } from "../store/usePreviewMode";
import { PdfPreview } from "./PdfPreview"; import { PdfPreview } from "./PdfPreview";
import { CoverArt } from "./CoverArt"; import { CoverArt } from "./CoverArt";
import { DeviceFrame } from "./DeviceFrame";
const TRIMS: { value: TrimSize; label: string }[] = [ const TRIMS: { value: TrimSize; label: string }[] = [
{ value: "6x9", label: "6 × 9 in" }, { value: "6x9", label: "6 × 9 in" },
@@ -15,26 +18,77 @@ const TRIMS: { value: TrimSize; label: string }[] = [
{ value: "a5", label: "A5" }, { value: "a5", label: "A5" },
]; ];
function TrimSelect() { function PreviewSelect() {
const trim = useBook((s) => s.book?.settings.trim ?? "6x9"); const trim = useBook((s) => s.book?.settings.trim ?? "6x9");
const setSettings = useBook((s) => s.setSettings); const setSettings = useBook((s) => s.setSettings);
const mode = usePreviewMode((s) => s.mode);
const setMode = usePreviewMode((s) => s.setMode);
const value = mode === "print" ? trim : mode;
return ( return (
<select <select
className="trim-select" className="trim-select"
value={trim} value={value}
onChange={(e) => setSettings({ trim: e.target.value as TrimSize })} onChange={(e) => {
title="Trim size" const v = e.target.value;
if (findDevice(v)) {
setMode(v as Device["id"]);
} else {
setSettings({ trim: v as TrimSize });
setMode("print");
}
}}
title="Preview format"
> >
<optgroup label="Print">
{TRIMS.map((t) => ( {TRIMS.map((t) => (
<option key={t.value} value={t.value}> <option key={t.value} value={t.value}>
{t.label} {t.label}
</option> </option>
))} ))}
</optgroup>
<optgroup label="Devices">
{DEVICES.map((d) => (
<option key={d.id} value={d.id}>
{d.label}
</option>
))}
</optgroup>
</select> </select>
); );
} }
function DockHead({ meta }: { meta?: ReactNode }) {
return (
<div className="dock-head">
<span className="label">Preview</span>
<div className="dock-tools">
{meta}
<PreviewSelect />
</div>
</div>
);
}
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
const kind = chapterKind(chapter);
return kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
}
function useActiveChapter() {
const book = useBook((s) => s.book);
const activeChapterId = useBook((s) => s.activeChapterId);
const chapters = book?.chapters ?? [];
const idx = chapters.findIndex((c) => c.id === activeChapterId);
const chapter = chapters[idx] ?? chapters[0];
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
const eyebrow = chapter ? chapterEyebrow(chapter, chapters, idx) : "";
return { book, coverActive: activeChapterId === COVER_ID, chapters, idx, chapter, html, eyebrow };
}
export function Dock() { export function Dock() {
const mode = usePreviewMode((s) => s.mode);
const device = findDevice(mode);
if (device) return <DeviceDock device={device} />;
return isDesktop ? <PdfDock /> : <HtmlDock />; return isDesktop ? <PdfDock /> : <HtmlDock />;
} }
@@ -53,8 +107,8 @@ function PdfDock() {
clearTimeout(timer.current); clearTimeout(timer.current);
setBusy(true); setBusy(true);
timer.current = setTimeout(() => { timer.current = setTimeout(() => {
const { source, images } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx); const { source, images, fonts } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
compilePdf(source, images) compilePdf(source, images, false, fonts)
.then((bytes) => { .then((bytes) => {
setPdf(bytes); setPdf(bytes);
setError(null); setError(null);
@@ -67,13 +121,7 @@ function PdfDock() {
return ( return (
<section className="dock"> <section className="dock">
<div className="dock-head"> <DockHead meta={busy ? <span className="meta">updating…</span> : null} />
<span className="label">Preview</span>
<div className="dock-tools">
{busy && <span className="meta">updating…</span>}
<TrimSelect />
</div>
</div>
{error ? ( {error ? (
<pre className="dock-error">{error}</pre> <pre className="dock-error">{error}</pre>
) : pdf ? ( ) : pdf ? (
@@ -86,26 +134,14 @@ function PdfDock() {
} }
function HtmlDock() { function HtmlDock() {
const book = useBook((s) => s.book); const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
const activeChapterId = useBook((s) => s.activeChapterId);
const coverActive = activeChapterId === COVER_ID;
const chapters = book?.chapters ?? [];
const idx = chapters.findIndex((c) => c.id === activeChapterId);
const chapter = chapters[idx] ?? chapters[0];
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
if (!book) return null; if (!book) return null;
if (coverActive) { if (coverActive) {
return ( return (
<section className="dock"> <section className="dock">
<div className="dock-head"> <DockHead meta={<span className="meta">Cover</span>} />
<span className="label">Preview</span>
<div className="dock-tools">
<span className="meta">Cover</span>
<TrimSelect />
</div>
</div>
<div className="cover-stage"> <div className="cover-stage">
<CoverArt book={book} /> <CoverArt book={book} />
</div> </div>
@@ -115,21 +151,15 @@ function HtmlDock() {
if (!chapter) return null; if (!chapter) return null;
const kind = chapterKind(chapter);
const eyebrow =
kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
return ( return (
<section className="dock"> <section className="dock">
<div className="dock-head"> <DockHead
<span className="label">Preview</span> meta={
<div className="dock-tools">
<span className="meta"> <span className="meta">
{idx + 1} of {chapters.length} {idx + 1} of {chapters.length}
</span> </span>
<TrimSelect /> }
</div> />
</div>
<div className="page"> <div className="page">
<div className="p-opener"> <div className="p-opener">
<div className="p-num">{eyebrow}</div> <div className="p-num">{eyebrow}</div>
@@ -141,3 +171,45 @@ function HtmlDock() {
</section> </section>
); );
} }
function DeviceDock({ device }: { device: Device }) {
const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
if (!book) return null;
if (coverActive) {
return (
<section className="dock">
<DockHead meta={<span className="meta">Cover</span>} />
<DeviceFrame device={device} cover={book} />
</section>
);
}
if (!chapter) {
return (
<section className="dock">
<DockHead />
<div className="dock-empty">No chapter selected.</div>
</section>
);
}
return (
<section className="dock">
<DockHead
meta={
<span className="meta">
{idx + 1} of {chapters.length}
</span>
}
/>
<DeviceFrame
device={device}
eyebrow={eyebrow}
title={chapter.noTitle ? undefined : chapter.title || "Untitled"}
html={html}
/>
</section>
);
}
+16 -2
View File
@@ -5,6 +5,7 @@ import { Dock } from "./Dock";
import { ResizeHandle } from "./ResizeHandle"; import { ResizeHandle } from "./ResizeHandle";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { Settings } from "./Settings"; import { Settings } from "./Settings";
import { BackupButton } from "./BackupButton";
import { CoverView } from "./CoverView"; import { CoverView } from "./CoverView";
import { FindBar } from "./FindBar"; import { FindBar } from "./FindBar";
import { ProofPopover } from "./ProofPopover"; import { ProofPopover } from "./ProofPopover";
@@ -12,6 +13,7 @@ import { Editor } from "../editor/Editor";
import { FloatingToolbar } from "../editor/FloatingToolbar"; import { FloatingToolbar } from "../editor/FloatingToolbar";
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing"; import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
import { COVER_ID, useBook } from "../store/useBook"; import { COVER_ID, useBook } from "../store/useBook";
import { useBackup } from "../store/useBackup";
import { useProofing } from "../store/useProofing"; import { useProofing } from "../store/useProofing";
import { useTheme } from "../store/useTheme"; import { useTheme } from "../store/useTheme";
import { useWidth } from "../store/useWidth"; import { useWidth } from "../store/useWidth";
@@ -75,8 +77,14 @@ export function EditorView() {
const saveNow = useCallback(() => { const saveNow = useCallback(() => {
const current = useBook.getState().book; const current = useBook.getState().book;
if (current) saveBook(current).then(markSaved).catch((e) => setNotice(`Save failed: ${e}`)); if (!current) return;
}, [markSaved]); saveBook(current)
.then(() => {
if (useBook.getState().book === current) markSaved();
useBackup.getState().refresh();
})
.catch((e) => setNotice(`Save failed: ${e}`));
}, [markSaved, setNotice]);
useEffect(() => { useEffect(() => {
if (!book || !dirty || !isDesktop) return; if (!book || !dirty || !isDesktop) return;
@@ -158,6 +166,7 @@ export function EditorView() {
{dirty && <span className="dirty-dot" />} {dirty && <span className="dirty-dot" />}
</button> </button>
<div className="actions"> <div className="actions">
{isDesktop && <BackupButton />}
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)"> <button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /> <Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
</button> </button>
@@ -263,10 +272,15 @@ export function EditorView() {
)} )}
</header> </header>
<Editor <Editor
key={chapter.id}
bookId={book.id}
chapterId={chapter.id} chapterId={chapter.id}
content={chapter.content} content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)} onChange={(content) => setChapterContent(chapter.id, content)}
onReady={setEditor} onReady={setEditor}
onContentError={() =>
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
}
/> />
</article> </article>
<FloatingToolbar editor={editor} /> <FloatingToolbar editor={editor} />
+46 -7
View File
@@ -1,18 +1,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { type BookSummary, deleteBook, exampleBook, listBooks, loadBook, newBook, saveBook } from "../library"; import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library";
import type { Book } from "../model/book"; import type { Book } from "../model/book";
import { importEpub } from "../import/epub"; import { importEpub } from "../import/epub";
import { clearPositions } from "../editor/positions";
import { isDesktop } from "../ipc"; import { isDesktop } from "../ipc";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { RowMenu } from "./RowMenu"; import { RowMenu } from "./RowMenu";
import { BackupButton } from "./BackupButton";
import { useBackup } from "../store/useBackup";
import { useBook } from "../store/useBook";
export function Library({ onOpen }: { onOpen: (book: Book) => void }) { export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
const [books, setBooks] = useState<BookSummary[]>([]); const [books, setBooks] = useState<BookSummary[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null); const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
const notice = useBook((s) => s.notice);
const setNotice = useBook((s) => s.setNotice);
const restoreNonce = useBackup((s) => s.restoreNonce);
const restoreWorking = useBackup((s) => s.phase === "working");
const restoreFromDrive = useBackup((s) => s.restoreFromDrive);
const refresh = () => const refresh = () =>
listBooks() listBooks()
@@ -22,6 +30,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, []); }, []);
useEffect(() => {
if (restoreNonce) refresh();
}, [restoreNonce]);
useEffect(() => {
if (!notice) return;
const timer = setTimeout(() => setNotice(null), 4000);
return () => clearTimeout(timer);
}, [notice]);
const handleExample = async () => { const handleExample = async () => {
const copy = exampleBook(); const copy = exampleBook();
@@ -38,6 +54,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
const removeBook = async () => { const removeBook = async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
await deleteBook(pendingDelete.id); await deleteBook(pendingDelete.id);
clearPositions(pendingDelete.id);
setPendingDelete(null); setPendingDelete(null);
refresh(); refresh();
}; };
@@ -62,9 +79,11 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
return ( return (
<div className="library"> <div className="library">
<header className="library-head" data-tauri-drag-region /> <header className="library-head" data-tauri-drag-region>
{isDesktop && <BackupButton />}
</header>
<div className="shelf"> <div className="shelf">
<button className="card card-action" onClick={() => onOpen(newBook())}> <button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
<Icon d="M12 5v14M5 12h14" size={20} /> <Icon d="M12 5v14M5 12h14" size={20} />
<span>New book</span> <span>New book</span>
</button> </button>
@@ -78,14 +97,34 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<span className="card-badge">Example</span> <span className="card-badge">Example</span>
</button> </button>
)} )}
{books.map((b) => ( {books.map((b) =>
<div key={b.id} className="card card-book" onClick={() => loadBook(b.id).then(onOpen)}> b.corrupt ? (
<div key={b.id} className="card card-book card-corrupt">
<span className="card-title">{b.title}</span>
<span className="card-author">Couldn't be read; a .bak backup may sit beside it.</span>
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div>
) : (
<div
key={b.id}
className="card card-book"
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))}
>
<span className="card-title">{b.title || "Untitled"}</span> <span className="card-title">{b.title || "Untitled"}</span>
{b.author && <span className="card-author">{b.author}</span>} {b.author && <span className="card-author">{b.author}</span>}
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} /> <RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div> </div>
))} ),
)}
</div> </div>
{loaded && books.length === 0 && isDesktop && (
<div className="library-restore">
<button className="restore-link" onClick={restoreFromDrive} disabled={restoreWorking}>
<Icon d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" size={15} />
{restoreWorking ? "Restoring…" : "Restore from Google Drive"}
</button>
</div>
)}
{notice && ( {notice && (
<div className="toast" onClick={() => setNotice(null)}> <div className="toast" onClick={() => setNotice(null)}>
{notice} {notice}
+19 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist"; import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
@@ -6,14 +6,17 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
export function PdfPreview({ data }: { data: Uint8Array }) { export function PdfPreview({ data }: { data: Uint8Array }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const container = ref.current; const container = ref.current;
if (!container) return; if (!container) return;
let cancelled = false; let cancelled = false;
const loadingTask = pdfjs.getDocument({ data: data.slice() });
(async () => { (async () => {
const doc = await pdfjs.getDocument({ data: data.slice() }).promise; try {
const doc = await loadingTask.promise;
if (cancelled) return; if (cancelled) return;
const width = container.clientWidth; const width = container.clientWidth;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
@@ -30,18 +33,29 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
canvas.width = viewport.width; canvas.width = viewport.width;
canvas.height = viewport.height; canvas.height = viewport.height;
canvas.style.width = "100%"; canvas.style.width = "100%";
const context = canvas.getContext("2d")!; await page.render({ canvas, viewport }).promise;
await page.render({ canvas, canvasContext: context, viewport }).promise;
if (cancelled) return; if (cancelled) return;
canvases.push(canvas); canvases.push(canvas);
} }
container.replaceChildren(...canvases); container.replaceChildren(...canvases);
setError(null);
} catch (e) {
if (cancelled) return;
container.replaceChildren();
setError(String(e));
}
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
loadingTask.destroy();
}; };
}, [data]); }, [data]);
return <div className="pdf-pages" ref={ref} />; return (
<>
{error && <pre className="dock-error">{error}</pre>}
<div className="pdf-pages" ref={ref} />
</>
);
} }
+102 -3
View File
@@ -1,6 +1,18 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
import { listSystemFonts } from "../ipc";
import {
type BookFonts,
type FontRef,
BUNDLED_FONTS,
DEFAULT_FONTS,
FONT_PAIRINGS,
decodeRef,
encodeRef,
fontStack,
pairingFor,
} from "../model/fonts";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
const LANGUAGES = [ const LANGUAGES = [
@@ -12,6 +24,8 @@ const LANGUAGES = [
{ value: "pt", label: "Portuguese" }, { value: "pt", label: "Portuguese" },
]; ];
let systemFontCache: string[] | null = null;
interface Draft { interface Draft {
title: string; title: string;
subtitle: string; subtitle: string;
@@ -19,6 +33,7 @@ interface Draft {
isbn: string; isbn: string;
language: string; language: string;
bleed: boolean; bleed: boolean;
fonts: BookFonts;
} }
function Field({ label, children }: { label: string; children: ReactNode }) { function Field({ label, children }: { label: string; children: ReactNode }) {
@@ -30,6 +45,30 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
); );
} }
function FontSelect({ value, system, onChange }: { value: FontRef; system: string[]; onChange: (ref: FontRef) => void }) {
const extra = value.kind === "system" && !system.includes(value.family) ? [value.family] : [];
return (
<select value={encodeRef(value)} style={{ fontFamily: fontStack(value) }} onChange={(e) => onChange(decodeRef(e.target.value))}>
<optgroup label="Bundled">
{BUNDLED_FONTS.map((f) => (
<option key={f.id} value={`b:${f.id}`}>
{f.label}
</option>
))}
</optgroup>
{(system.length > 0 || extra.length > 0) && (
<optgroup label="System">
{[...extra, ...system].map((name) => (
<option key={name} value={`s:${name}`}>
{name}
</option>
))}
</optgroup>
)}
</select>
);
}
export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () => void }) { export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
const book = useBook((s) => s.book); const book = useBook((s) => s.book);
const setMetadata = useBook((s) => s.setMetadata); const setMetadata = useBook((s) => s.setMetadata);
@@ -42,16 +81,31 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
isbn: book?.metadata.isbn ?? "", isbn: book?.metadata.isbn ?? "",
language: book?.metadata.language ?? "en", language: book?.metadata.language ?? "en",
bleed: book?.settings.bleed ?? true, bleed: book?.settings.bleed ?? true,
fonts: book?.settings.fonts ?? DEFAULT_FONTS,
})); }));
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null);
useEffect(() => {
if (systemFontCache) return;
listSystemFonts().then((fonts) => {
systemFontCache = fonts;
setSystem(fonts);
});
}, []);
if (!book) return null; if (!book) return null;
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch })); const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
const setFont = (slot: "body" | "heading", ref: FontRef) => set({ fonts: { ...draft.fonts, [slot]: ref } });
const activePreset = pairingFor(draft.fonts);
const usesSystem = draft.fonts.body.kind === "system" || draft.fonts.heading.kind === "system";
const save = () => { const save = () => {
const { bleed, ...metadata } = draft; const { bleed, fonts, ...metadata } = draft;
setMetadata(metadata); setMetadata(metadata);
setSettings({ bleed }); setSettings({ bleed, fonts });
onSave(); onSave();
onClose(); onClose();
}; };
@@ -87,6 +141,51 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
))} ))}
</select> </select>
</Field> </Field>
<div className="field">
<span className="field-label">Typography</span>
<div className="font-presets">
{FONT_PAIRINGS.map((p) => (
<button
key={p.id}
type="button"
className="font-preset"
data-on={activePreset === p.id}
onClick={() => set({ fonts: { body: p.body, heading: p.heading } })}
>
<span className="font-preset-demo" style={{ fontFamily: fontStack(p.heading) }}>
Ag
</span>
<span className="font-preset-name">{p.label}</span>
</button>
))}
</div>
<button type="button" className="font-advanced" onClick={() => setAdvanced((v) => !v)}>
{advanced ? "Hide custom fonts" : "Customize fonts"}
</button>
</div>
{advanced && (
<>
<Field label="Body font">
<FontSelect value={draft.fonts.body} system={system} onChange={(ref) => setFont("body", ref)} />
</Field>
<Field label="Heading font">
<FontSelect value={draft.fonts.heading} system={system} onChange={(ref) => setFont("heading", ref)} />
</Field>
{usesSystem && (
<p className="font-note">System fonts preview here but may not embed in exported PDF or EPUB, so they can look different on other devices.</p>
)}
</>
)}
<div className="font-sample" style={{ fontFamily: fontStack(draft.fonts.body) }}>
<span className="font-sample-title" style={{ fontFamily: fontStack(draft.fonts.heading) }}>
Chapter One
</span>
She read the first line twice, and the whole quiet house seemed to lean in to listen.
</div>
<label className="check"> <label className="check">
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} /> <input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
Add bleed for full-page images (print) Add bleed for full-page images (print)
+115
View File
@@ -0,0 +1,115 @@
export type DeviceId = "kindle" | "kobo" | "ipad" | "iphone" | "android";
export type PreviewMode = "print" | DeviceId;
export type DeviceKind = "eink" | "phone" | "tablet";
export interface Device {
id: DeviceId;
label: string;
kind: DeviceKind;
bg: string;
ink: string;
fontSize: number;
lineHeight: number;
marginX: number;
marginY: number;
aspect: number;
bezel: string;
bezelWidth: number;
radius: number;
}
export const DEVICES: Device[] = [
{
id: "kindle",
label: "Kindle Paperwhite",
kind: "eink",
bg: "#f4f3ee",
ink: "#2b2b2b",
fontSize: 16,
lineHeight: 1.55,
marginX: 34,
marginY: 40,
aspect: 1240 / 1680,
bezel: "#1c1c1c",
bezelWidth: 18,
radius: 14,
},
{
id: "kobo",
label: "Kobo",
kind: "eink",
bg: "#f6f5f0",
ink: "#262626",
fontSize: 16,
lineHeight: 1.6,
marginX: 34,
marginY: 40,
aspect: 1264 / 1680,
bezel: "#2a2a2a",
bezelWidth: 16,
radius: 16,
},
{
id: "ipad",
label: "iPad",
kind: "tablet",
bg: "#fbfaf7",
ink: "#1d1d1f",
fontSize: 18,
lineHeight: 1.65,
marginX: 56,
marginY: 56,
aspect: 1640 / 2360,
bezel: "#3a3a3c",
bezelWidth: 20,
radius: 28,
},
{
id: "iphone",
label: "iPhone",
kind: "phone",
bg: "#ffffff",
ink: "#1d1d1f",
fontSize: 17,
lineHeight: 1.6,
marginX: 26,
marginY: 30,
aspect: 1179 / 2556,
bezel: "#1a1a1c",
bezelWidth: 12,
radius: 44,
},
{
id: "android",
label: "Android phone",
kind: "phone",
bg: "#ffffff",
ink: "#202124",
fontSize: 16,
lineHeight: 1.6,
marginX: 24,
marginY: 28,
aspect: 1080 / 2400,
bezel: "#0f0f0f",
bezelWidth: 11,
radius: 36,
},
];
const DEVICE_MAP = new Map<string, Device>(DEVICES.map((d) => [d.id, d]));
const KEY = "margin-preview-mode";
export function findDevice(id: string): Device | undefined {
return DEVICE_MAP.get(id);
}
export function initialPreviewMode(): PreviewMode {
const saved = localStorage.getItem(KEY);
if (saved === "print" || (saved && DEVICE_MAP.has(saved))) return saved as PreviewMode;
return "print";
}
export function persistPreviewMode(mode: PreviewMode) {
localStorage.setItem(KEY, mode);
}
+48 -27
View File
@@ -2,67 +2,88 @@ import { useEffect, useRef } from "react";
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"; import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions"; import { editorExtensions } from "./extensions";
import { loadPosition, savePosition, type ChapterPosition } from "./positions";
interface EditorProps { interface EditorProps {
bookId: string;
chapterId: string; chapterId: string;
content: JSONContent; content: JSONContent;
onChange: (content: JSONContent) => void; onChange: (content: JSONContent) => void;
onReady: (editor: TiptapEditor | null) => void; onReady: (editor: TiptapEditor | null) => void;
onContentError: (error: Error) => void;
} }
interface ChapterPosition { export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
from: number;
to: number;
scroll: number;
}
export function Editor({ chapterId, content, onChange, onReady }: EditorProps) {
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; onChangeRef.current = onChange;
const onContentErrorRef = useRef(onContentError);
onContentErrorRef.current = onContentError;
const positions = useRef(new Map<string, ChapterPosition>()); const latest = useRef<ChapterPosition | null>(null);
const openChapter = useRef(chapterId);
const editor = useEditor({ const editor = useEditor({
extensions: editorExtensions, extensions: editorExtensions,
content, content,
immediatelyRender: false, immediatelyRender: false,
enableContentCheck: true,
editorProps: { attributes: { class: "prose" } }, editorProps: { attributes: { class: "prose" } },
onContentError: ({ error }) => onContentErrorRef.current(error),
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()), onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
}); });
useEffect(() => { useEffect(() => {
onReady(editor); onReady(editor);
return () => onReady(null);
}, [editor, onReady]); }, [editor, onReady]);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
let active = true;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null; const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
const previous = openChapter.current; const saved = loadPosition(bookId, chapterId);
if (previous !== chapterId) {
const { from, to } = editor.state.selection;
positions.current.set(previous, { from, to, scroll: scroller?.scrollTop ?? 0 });
}
openChapter.current = chapterId;
editor.commands.setContent(content, { emitUpdate: false });
const saved = positions.current.get(chapterId);
const size = editor.state.doc.content.size; const size = editor.state.doc.content.size;
editor.commands.setTextSelection( const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0 editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run();
);
if (scroller) { if (scroller) {
const top = saved?.scroll ?? 0; const top = saved?.scroll ?? 0;
scroller.scrollTop = top; const restore = () => {
requestAnimationFrame(() => { if (active) scroller.scrollTop = top;
scroller.scrollTop = top; };
}); restore();
requestAnimationFrame(restore);
document.fonts?.ready.then(restore).catch(() => {});
} }
return () => {
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [chapterId, editor]); }, [editor]);
useEffect(() => {
if (!editor) return;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
let timer: ReturnType<typeof setTimeout>;
const capture = () => {
const { from, to } = editor.state.selection;
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 };
};
const persist = () => {
capture();
clearTimeout(timer);
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400);
};
editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true });
return () => {
clearTimeout(timer);
editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", persist);
if (latest.current) savePosition(bookId, chapterId, latest.current);
};
}, [editor, bookId, chapterId]);
return <EditorContent editor={editor} className="editor-host" />; return <EditorContent editor={editor} className="editor-host" />;
} }
+64
View File
@@ -0,0 +1,64 @@
export interface ChapterPosition {
from: number;
to: number;
scroll: number;
}
const KEY = "margin-positions";
type Store = Record<string, Record<string, ChapterPosition>>;
function readAll(): Store {
try {
return JSON.parse(localStorage.getItem(KEY) || "{}") as Store;
} catch {
return {};
}
}
function write(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
return;
}
}
export function loadPosition(bookId: string, chapterId: string): ChapterPosition | null {
return readAll()[bookId]?.[chapterId] ?? null;
}
export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void {
const all = readAll();
(all[bookId] ||= {})[chapterId] = position;
write(KEY, all);
}
const ACTIVE_KEY = "margin-active-chapter";
function readActive(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem(ACTIVE_KEY) || "{}") as Record<string, string>;
} catch {
return {};
}
}
export function loadActiveChapter(bookId: string): string | null {
return readActive()[bookId] ?? null;
}
export function saveActiveChapter(bookId: string, chapterId: string): void {
const all = readActive();
all[bookId] = chapterId;
write(ACTIVE_KEY, all);
}
export function clearPositions(bookId: string): void {
const positions = readAll();
const active = readActive();
delete positions[bookId];
delete active[bookId];
write(KEY, positions);
write(ACTIVE_KEY, active);
}
+78 -31
View File
@@ -1,6 +1,7 @@
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book"; import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
import { fontStack, fontsUsed } from "../model/fonts";
export interface EpubFile { export interface EpubFile {
path: string; path: string;
@@ -25,7 +26,7 @@ function slug(text: string): string {
function imageExtension(dataUrl: string): string { function imageExtension(dataUrl: string): string {
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl); const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
const kind = (match?.[1] ?? "png").toLowerCase(); const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
return kind === "jpeg" ? "jpg" : kind; return kind === "jpeg" ? "jpg" : kind;
} }
@@ -38,12 +39,65 @@ function mediaType(path: string): string {
return "application/octet-stream"; return "application/octet-stream";
} }
interface FontAssets {
files: EpubFile[];
faces: string;
manifest: string[];
}
function base64FromBuffer(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
async function fetchFontBase64(file: string): Promise<string | null> {
try {
const res = await fetch(`/fonts/${file}`);
if (!res.ok) return null;
return base64FromBuffer(await res.arrayBuffer());
} catch {
return null;
}
}
async function loadFontAssets(book: Book): Promise<FontAssets> {
const used = fontsUsed(book.settings.fonts).bundled;
const files: EpubFile[] = [];
const faces: string[] = [];
const manifest: string[] = [];
let n = 0;
for (const font of used) {
const variants = [{ file: font.regular, style: "normal" }];
if (font.italic) variants.push({ file: font.italic, style: "italic" });
for (const variant of variants) {
const data = await fetchFontBase64(variant.file);
if (!data) continue;
const href = `fonts/${variant.file}`;
const id = `font-${++n}`;
files.push({ path: `OEBPS/${href}`, data, encoding: "base64" });
manifest.push(`<item id="${id}" href="${attr(href)}" media-type="font/ttf"/>`);
faces.push(
`@font-face {\n font-family: "${font.family}";\n font-style: ${variant.style};\n font-weight: ${font.weight};\n src: url("${href}");\n}`,
);
}
}
return { files, faces: faces.join("\n\n"), manifest };
}
function inline(node: JSONContent): string { function inline(node: JSONContent): string {
if (node.type === "text") { if (node.type === "text") {
let text = esc(node.text ?? ""); let text = esc(node.text ?? "");
const marks = node.marks ?? []; const marks = node.marks ?? [];
if (marks.some((m) => m.type === "code")) text = `<code>${text}</code>`;
if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`; if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`;
if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`; if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`;
if (marks.some((m) => m.type === "strike")) text = `<s>${text}</s>`;
if (marks.some((m) => m.type === "underline")) text = `<u>${text}</u>`;
const href = marks.find((m) => m.type === "link")?.attrs?.href; const href = marks.find((m) => m.type === "link")?.attrs?.href;
if (href) text = `<a href="${attr(href)}">${text}</a>`; if (href) text = `<a href="${attr(href)}">${text}</a>`;
return text; return text;
@@ -56,11 +110,11 @@ function inlines(content: JSONContent[] = []): string {
return content.map(inline).join(""); return content.map(inline).join("");
} }
function listItem(item: JSONContent): string { function listItem(item: JSONContent, paths: Map<string, string>): string {
const inner = (item.content ?? []) const inner = (item.content ?? [])
.filter((c) => c.type === "paragraph") .map((child) => block(child, paths))
.map((p) => inlines(p.content)) .filter((s) => s.length > 0)
.join(" "); .join("");
return `<li>${inner}</li>`; return `<li>${inner}</li>`;
} }
@@ -92,9 +146,9 @@ function block(node: JSONContent, paths: Map<string, string>): string {
case "blockquote": case "blockquote":
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`; return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
case "bulletList": case "bulletList":
return `<ul>${(node.content ?? []).map(listItem).join("")}</ul>`; return `<ul>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ul>`;
case "orderedList": case "orderedList":
return `<ol>${(node.content ?? []).map(listItem).join("")}</ol>`; return `<ol>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ol>`;
case "horizontalRule": case "horizontalRule":
return `<hr class="scene-break"/>`; return `<hr class="scene-break"/>`;
case "figure": case "figure":
@@ -237,6 +291,7 @@ function contentOpf(
chapterIds: string[], chapterIds: string[],
assetPaths: string[], assetPaths: string[],
cover: { href: string; mediaType: string }, cover: { href: string; mediaType: string },
fontManifest: string[],
): string { ): string {
const meta = book.metadata; const meta = book.metadata;
const manifest: string[] = [ const manifest: string[] = [
@@ -244,8 +299,7 @@ function contentOpf(
`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`, `<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`,
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`, `<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
`<item id="css" href="style.css" media-type="text/css"/>`, `<item id="css" href="style.css" media-type="text/css"/>`,
`<item id="font-regular" href="fonts/Literata-VF.ttf" media-type="font/ttf"/>`, ...fontManifest,
`<item id="font-italic" href="fonts/Literata-Italic-VF.ttf" media-type="font/ttf"/>`,
]; ];
chapterIds.forEach((_, i) => { chapterIds.forEach((_, i) => {
manifest.push( manifest.push(
@@ -331,23 +385,9 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
</html>`; </html>`;
} }
function styleCss(): string { function styleCss(book: Book, faces: string): string {
return `@font-face { return `${faces ? faces + "\n\n" : ""}html {
font-family: "Literata"; font-family: ${fontStack(book.settings.fonts.body)};
font-style: normal;
font-weight: 300 700;
src: url("fonts/Literata-VF.ttf");
}
@font-face {
font-family: "Literata";
font-style: italic;
font-weight: 300 700;
src: url("fonts/Literata-Italic-VF.ttf");
}
html {
font-family: "Literata", Georgia, serif;
} }
.cover { .cover {
@@ -380,6 +420,7 @@ p[data-indent="true"] {
} }
h1, h2, h3 { h1, h2, h3 {
font-family: ${fontStack(book.settings.fonts.heading)};
hyphens: none; hyphens: none;
line-height: 1.25; line-height: 1.25;
font-weight: 500; font-weight: 500;
@@ -406,6 +447,11 @@ h2, h3 {
margin: 0; margin: 0;
} }
code {
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
}
blockquote { blockquote {
margin: 1.2em 1.6em; margin: 1.2em 1.6em;
font-style: italic; font-style: italic;
@@ -469,7 +515,7 @@ figure.placement-float-top {
`; `;
} }
export function bookToEpub(book: Book): EpubFile[] { export function bookToEpub(book: Book, fontAssets: FontAssets): EpubFile[] {
const { assets, paths } = extractFigures(book); const { assets, paths } = extractFigures(book);
const cover = buildCover(book); const cover = buildCover(book);
const chapterIds = book.chapters.map((c) => c.id); const chapterIds = book.chapters.map((c) => c.id);
@@ -480,12 +526,12 @@ export function bookToEpub(book: Book): EpubFile[] {
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" }, { path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
{ {
path: "OEBPS/content.opf", path: "OEBPS/content.opf",
data: contentOpf(book, chapterIds, assetPaths, cover), data: contentOpf(book, chapterIds, assetPaths, cover, fontAssets.manifest),
encoding: "utf8", encoding: "utf8",
}, },
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" }, { path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" }, { path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
{ path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" }, { path: "OEBPS/style.css", data: styleCss(book, fontAssets.faces), encoding: "utf8" },
cover.asset, cover.asset,
]; ];
@@ -497,12 +543,13 @@ export function bookToEpub(book: Book): EpubFile[] {
}); });
}); });
files.push(...assets); files.push(...assets, ...fontAssets.files);
return files; return files;
} }
export async function buildEpub(book: Book): Promise<Uint8Array> { export async function buildEpub(book: Book): Promise<Uint8Array> {
const files = bookToEpub(book); const fontAssets = await loadFontAssets(book);
const files = bookToEpub(book, fontAssets);
const buf = await invoke<ArrayBuffer>("package_epub", { files }); const buf = await invoke<ArrayBuffer>("package_epub", { files });
return new Uint8Array(buf); return new Uint8Array(buf);
} }
+2 -2
View File
@@ -8,8 +8,8 @@ const PDF = [{ name: "PDF", extensions: ["pdf"] }];
const EPUB = [{ name: "EPUB", extensions: ["epub"] }]; const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
export async function exportPdf(book: Book): Promise<void> { export async function exportPdf(book: Book): Promise<void> {
const { source, images } = bookToPdfInputs(book); const { source, images, fonts } = bookToPdfInputs(book);
const bytes = await compilePdf(source, images); const bytes = await compilePdf(source, images, true, fonts);
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF); await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
} }
+6
View File
@@ -1,5 +1,6 @@
import type { Book } from "../model/book"; import type { Book } from "../model/book";
import { exportEpub, exportPdf } from "./exporters"; import { exportEpub, exportPdf } from "./exporters";
import { unsupportedScripts } from "./typst";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
import { isDesktop } from "../ipc"; import { isDesktop } from "../ipc";
@@ -29,6 +30,11 @@ export async function runExport(format: "pdf" | "epub"): Promise<void> {
await nextPaint(); await nextPaint();
try { try {
await run(book); await run(book);
if (format === "pdf") {
const scripts = unsupportedScripts(book);
if (scripts.length)
setNotice(`Some ${scripts.join(", ")} characters may not render — the PDF fonts cover Latin scripts only.`);
}
} catch (e) { } catch (e) {
setNotice(`${label} export failed: ${e}`); setNotice(`${label} export failed: ${e}`);
} finally { } finally {
+81 -33
View File
@@ -1,7 +1,18 @@
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book"; import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
import { fontFamilyName, fontsUsed } from "../model/fonts";
import type { ImageInput } from "../ipc"; import type { ImageInput } from "../ipc";
export interface PdfFonts {
bundled: string[];
system: string[];
}
function pdfFonts(book: Book): PdfFonts {
const used = fontsUsed(book.settings.fonts);
return { bundled: used.bundled.map((f) => f.id), system: used.system };
}
const TRIM: Record<TrimSize, { w: string; h: string }> = { const TRIM: Record<TrimSize, { w: string; h: string }> = {
"6x9": { w: "6in", h: "9in" }, "6x9": { w: "6in", h: "9in" },
"5.5x8.5": { w: "5.5in", h: "8.5in" }, "5.5x8.5": { w: "5.5in", h: "8.5in" },
@@ -10,27 +21,32 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
}; };
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g; const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g;
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
function esc(text: string): string { function esc(text: string): string {
return text.replace(INLINE_SPECIAL, (m) => "\\" + m); return text.replace(LINE_SEPARATORS, " ").replace(INLINE_SPECIAL, (m) => "\\" + m);
} }
function guardLineStart(line: string): string { function guardLineStart(text: string): string {
if (/^[=\-+/]/.test(line)) return "\\" + line; if (/^[=\-+/]/.test(text)) return "\\" + text;
if (/^\d+\./.test(line)) return line.replace(".", "\\."); if (/^\d+\./.test(text)) return text.replace(".", "\\.");
return line; return text;
} }
function str(value: string): string { function str(value: string): string {
return JSON.stringify(value); return JSON.stringify(value);
} }
function inline(node: JSONContent): string { function inline(node: JSONContent, atLineStart: boolean): string {
if (node.type === "text") { if (node.type === "text") {
let text = esc(node.text ?? "");
const marks = node.marks ?? []; const marks = node.marks ?? [];
if (marks.some((m) => m.type === "code")) return `#raw(${str(node.text ?? "")})`;
let text = esc(node.text ?? "");
if (atLineStart) text = guardLineStart(text);
if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`; if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`;
if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`; if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`;
if (marks.some((m) => m.type === "strike")) text = `#strike[${text}]`;
if (marks.some((m) => m.type === "underline")) text = `#underline[${text}]`;
const href = marks.find((m) => m.type === "link")?.attrs?.href; const href = marks.find((m) => m.type === "link")?.attrs?.href;
if (href) text = `#link(${str(href)})[${text}]`; if (href) text = `#link(${str(href)})[${text}]`;
return text; return text;
@@ -39,15 +55,15 @@ function inline(node: JSONContent): string {
return ""; return "";
} }
function inlines(content: JSONContent[] = []): string { function inlines(content: JSONContent[] = [], atLineStart = false): string {
return content.map(inline).join(""); return content.map((node, i) => inline(node, atLineStart && i === 0)).join("");
} }
function listItem(item: JSONContent): string { function listItem(item: JSONContent, paths: Map<string, string>): string {
return (item.content ?? []) const parts = (item.content ?? [])
.filter((c) => c.type === "paragraph") .map((child) => block(child, paths))
.map((p) => inlines(p.content)) .filter((s) => s.length > 0);
.join(" "); return `[${parts.join("\n\n")}]`;
} }
function figure(node: JSONContent, paths: Map<string, string>): string { function figure(node: JSONContent, paths: Map<string, string>): string {
@@ -59,7 +75,7 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
} }
const placed = placement as FigurePlacement; const placed = placement as FigurePlacement;
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100; const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
const caption = node.attrs?.caption ? `, caption: [${esc(node.attrs.caption)}]` : ""; const caption = node.attrs?.caption ? `, caption: [${guardLineStart(esc(node.attrs.caption))}]` : "";
const float = placement === "float-top" ? ", placement: top" : ""; const float = placement === "float-top" ? ", placement: top" : "";
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`; return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
} }
@@ -67,18 +83,18 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
function block(node: JSONContent, paths: Map<string, string>): string { function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) { switch (node.type) {
case "paragraph": { case "paragraph": {
const body = guardLineStart(inlines(node.content)); const body = inlines(node.content, true);
if (!body.trim()) return "~"; if (!body.trim()) return "~";
return node.attrs?.indent ? `#h(1.3em)${body}` : body; return node.attrs?.indent ? `#h(1.3em)${body}` : body;
} }
case "heading": case "heading":
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content)}]`; return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`;
case "blockquote": case "blockquote":
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`; return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
case "bulletList": case "bulletList":
return (node.content ?? []).map((li) => `- ${listItem(li)}`).join("\n"); return `#list(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
case "orderedList": case "orderedList":
return (node.content ?? []).map((li) => `+ ${listItem(li)}`).join("\n"); return `#enum(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
case "horizontalRule": case "horizontalRule":
return "#scenebreak"; return "#scenebreak";
case "figure": case "figure":
@@ -99,6 +115,8 @@ function preamble(book: Book): string {
const trim = TRIM[book.settings.trim]; const trim = TRIM[book.settings.trim];
const meta = book.metadata; const meta = book.metadata;
const bleed = book.settings.bleed ? "0.125in" : "0in"; const bleed = book.settings.bleed ? "0.125in" : "0in";
const body = fontFamilyName(book.settings.fonts.body);
const heading = fontFamilyName(book.settings.fonts.heading);
return `#set document(title: ${str(meta.title || "Untitled")}, author: ${str(meta.author)}) return `#set document(title: ${str(meta.title || "Untitled")}, author: ${str(meta.author)})
#set page( #set page(
width: ${trim.w} + ${bleed}, width: ${trim.w} + ${bleed},
@@ -106,9 +124,9 @@ function preamble(book: Book): string {
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}), margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
binding: left, binding: left,
) )
#set text(font: "Literata", size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true) #set text(font: ${str(body)}, size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
#set par(justify: true, leading: 0.72em, spacing: 0.72em) #set par(justify: true, leading: 0.72em, spacing: 0.72em)
#show heading: set text(font: "Literata", weight: "medium") #show heading: set text(font: ${str(heading)}, weight: "medium")
#show heading.where(level: 1): set text(size: 22pt) #show heading.where(level: 1): set text(size: 22pt)
#show heading.where(level: 2): set block(above: 1.4em, below: 0.6em) #show heading.where(level: 2): set block(above: 1.4em, below: 0.6em)
#show heading.where(level: 3): set block(above: 1.2em, below: 0.5em) #show heading.where(level: 3): set block(above: 1.2em, below: 0.5em)
@@ -120,10 +138,10 @@ function preamble(book: Book): string {
#let titlepage(title, subtitle, author) = { #let titlepage(title, subtitle, author) = {
v(2.4in) v(2.4in)
align(center)[ align(center)[
#text(font: "Literata", size: 30pt, weight: "medium")[#title] #text(font: ${str(heading)}, size: 30pt, weight: "medium")[#title]
#if subtitle != "" { #if subtitle != "" {
v(0.6em) v(0.6em)
text(font: "Literata", size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle] text(font: ${str(body)}, size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
} }
#if author != "" { #if author != "" {
v(1.7em) v(1.7em)
@@ -159,7 +177,7 @@ function preamble(book: Book): string {
set par(justify: false, first-line-indent: 0pt) set par(justify: false, first-line-indent: 0pt)
block(below: 2em)[ block(below: 2em)[
#show heading: set block(above: 0pt, below: 0pt) #show heading: set block(above: 0pt, below: 0pt)
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: "Literata", size: 24pt, weight: "medium")[Contents]] #heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: ${str(heading)}, size: 24pt, weight: "medium")[Contents]]
] ]
context { context {
let items = query(<chap>).filter(it => it.value.toc != false) let items = query(<chap>).filter(it => it.value.toc != false)
@@ -188,20 +206,22 @@ function preamble(book: Book): string {
function imageExtension(dataUrl: string): string { function imageExtension(dataUrl: string): string {
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl); const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
const kind = (match?.[1] ?? "png").toLowerCase(); const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
return kind === "jpeg" ? "jpg" : kind; return kind === "jpeg" ? "jpg" : kind;
} }
function coverBlock(book: Book, coverPath?: string): string { function coverBlock(book: Book, coverPath?: string): string {
const c = book.cover; const c = book.cover;
const meta = book.metadata; const meta = book.metadata;
const body = fontFamilyName(book.settings.fonts.body);
const heading = fontFamilyName(book.settings.fonts.heading);
if (c.kind === "image" && coverPath) { if (c.kind === "image" && coverPath) {
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`; return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
} }
const subtitle = meta.subtitle const subtitle = meta.subtitle
? `#v(0.55em)\n #text(font: "Literata", size: 14pt, style: "italic")[${esc(meta.subtitle)}]` ? `#v(0.55em)\n #text(font: ${str(body)}, size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
: ""; : "";
const author = meta.author const author = meta.author
? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)` ? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
@@ -211,7 +231,7 @@ function coverBlock(book: Book, coverPath?: string): string {
#set align(center) #set align(center)
#set text(fill: rgb(${str(c.ink)})) #set text(fill: rgb(${str(c.ink)}))
#v(1fr) #v(1fr)
#text(font: "Literata", size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}] #text(font: ${str(heading)}, size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
#v(0.5em) #v(0.5em)
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)})) #line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
${subtitle} ${subtitle}
@@ -251,6 +271,34 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map<st
return collectImages(book.chapters.map((chapter) => chapter.content)); return collectImages(book.chapters.map((chapter) => chapter.content));
} }
const SCRIPT_RANGES: { label: string; test: RegExp }[] = [
{ label: "CJK", test: /[぀-鿿가-힯豈-﫿]/u },
{ label: "Arabic", test: /[؀-ۿݐ-ݿ]/u },
{ label: "Hebrew", test: /[֐-׿]/u },
{ label: "Devanagari", test: /[ऀ-ॿ]/u },
{ label: "Thai", test: /[฀-๿]/u },
{ label: "emoji", test: /[☀-➿]|[\u{1f000}-\u{1faff}]/u },
];
function collectText(book: Book): string {
const parts = [book.metadata.title, book.metadata.subtitle, book.metadata.author];
const visit = (node: JSONContent) => {
if (node.type === "text" && node.text) parts.push(node.text);
if (node.attrs?.caption) parts.push(String(node.attrs.caption));
(node.content ?? []).forEach(visit);
};
book.chapters.forEach((chapter) => {
parts.push(chapter.title);
visit(chapter.content);
});
return parts.join("\n");
}
export function unsupportedScripts(book: Book): string[] {
const text = collectText(book);
return SCRIPT_RANGES.filter((s) => s.test.test(text)).map((s) => s.label);
}
function cleanTitle(title: string): string { function cleanTitle(title: string): string {
return (title || "") return (title || "")
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ") .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
@@ -301,16 +349,16 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`; return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`;
} }
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[] } { export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
const { images, paths } = extractImages(book); const { images, paths } = extractImages(book);
const coverPath = pushCoverImage(book, images); const coverPath = pushCoverImage(book, images);
return { source: bookToTypst(book, paths, coverPath), images }; return { source: bookToTypst(book, paths, coverPath), images, fonts: pdfFonts(book) };
} }
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[] } { export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
const images: ImageInput[] = []; const images: ImageInput[] = [];
const coverPath = pushCoverImage(book, images); const coverPath = pushCoverImage(book, images);
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images }; return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images, fonts: pdfFonts(book) };
} }
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string { function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
@@ -325,8 +373,8 @@ ${chapterBody(book.chapters[index].content, paths)}
`; `;
} }
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[] } { export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[]; fonts: PdfFonts } {
const chapter = book.chapters[index]; const chapter = book.chapters[index];
const { images, paths } = collectImages([chapter.content]); const { images, paths } = collectImages([chapter.content]);
return { source: chapterToTypst(book, index, paths), images }; return { source: chapterToTypst(book, index, paths), images, fonts: pdfFonts(book) };
} }
+123 -21
View File
@@ -1,7 +1,8 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { type Book, type BookMetadata, type Chapter, createCover } from "../model/book"; import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book";
import { DEFAULT_FONTS } from "../model/fonts";
export interface RawFile { export interface RawFile {
path: string; path: string;
@@ -9,7 +10,7 @@ export interface RawFile {
encoding: "utf8" | "base64"; encoding: "utf8" | "base64";
} }
type Mark = { type: string }; type Mark = { type: string; attrs?: Record<string, unknown> };
const BLOCK_TAGS = new Set([ const BLOCK_TAGS = new Set([
"p", "div", "section", "article", "main", "header", "footer", "aside", "p", "div", "section", "article", "main", "header", "footer", "aside",
@@ -81,8 +82,8 @@ function hasMark(marks: Mark[], type: string): boolean {
return marks.some((m) => m.type === type); return marks.some((m) => m.type === type);
} }
function addMark(marks: Mark[], type: string): Mark[] { function addMark(marks: Mark[], type: string, attrs?: Record<string, unknown>): Mark[] {
return hasMark(marks, type) ? marks : [...marks, { type }]; return hasMark(marks, type) ? marks : [...marks, attrs ? { type, attrs } : { type }];
} }
function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void { function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
@@ -102,6 +103,15 @@ function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
collectInline(el, addMark(marks, "bold"), out); collectInline(el, addMark(marks, "bold"), out);
} else if (tag === "em" || tag === "i") { } else if (tag === "em" || tag === "i") {
collectInline(el, addMark(marks, "italic"), out); collectInline(el, addMark(marks, "italic"), out);
} else if (tag === "u") {
collectInline(el, addMark(marks, "underline"), out);
} else if (tag === "s" || tag === "strike" || tag === "del") {
collectInline(el, addMark(marks, "strike"), out);
} else if (tag === "code") {
collectInline(el, addMark(marks, "code"), out);
} else if (tag === "a") {
const href = el.getAttribute("href");
collectInline(el, href ? addMark(marks, "link", { href }) : marks, out);
} else if (!BLOCK_TAGS.has(tag)) { } else if (!BLOCK_TAGS.has(tag)) {
collectInline(el, marks, out); collectInline(el, marks, out);
} }
@@ -137,13 +147,23 @@ function meaningful(inline: JSONContent[]): boolean {
function imgFigure(img: Element, caption = ""): JSONContent[] { function imgFigure(img: Element, caption = ""): JSONContent[] {
const src = img.getAttribute("src"); const src = img.getAttribute("src");
if (!src || !src.startsWith("data:")) return []; if (src && src.startsWith("data:")) {
return [ return [
{ {
type: "figure", type: "figure",
attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" }, attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" },
}, },
]; ];
}
const alt = (img.getAttribute("alt") ?? "").trim();
return alt ? [{ type: "paragraph", content: [{ type: "text", text: alt }] }] : [];
}
function svgFigure(el: Element): JSONContent[] {
const markup = el.outerHTML;
if (!markup) return [];
const src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(markup)))}`;
return [{ type: "figure", attrs: { src, alt: "", caption: "", placement: "full-width" } }];
} }
function figureFrom(el: Element): JSONContent[] { function figureFrom(el: Element): JSONContent[] {
@@ -165,18 +185,35 @@ function hasBlockChild(el: Element): boolean {
return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase())); return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase()));
} }
function listItems(el: Element): JSONContent[] { function listItems(el: Element, depth: number): JSONContent[] {
const items: JSONContent[] = []; const items: JSONContent[] = [];
Array.from(el.children) Array.from(el.children)
.filter((c) => c.localName.toLowerCase() === "li") .filter((c) => c.localName.toLowerCase() === "li")
.forEach((li) => { .forEach((li) => {
const content = blocksFrom(li); const content = blocksFrom(li, depth + 1);
items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] }); items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] });
}); });
return items; return items;
} }
function blockFromElement(el: Element): JSONContent[] { function tableBlocks(el: Element): JSONContent[] {
const out: JSONContent[] = [];
Array.from(el.querySelectorAll("tr")).forEach((tr) => {
const content: JSONContent[] = [];
Array.from(tr.children)
.filter((c) => c.localName.toLowerCase() === "td" || c.localName.toLowerCase() === "th")
.forEach((cell) => {
const inline = inlineOf(cell);
if (!meaningful(inline)) return;
if (content.length) content.push({ type: "text", text: " · " });
content.push(...inline);
});
if (meaningful(content)) out.push({ type: "paragraph", content });
});
return out;
}
function blockFromElement(el: Element, depth: number): JSONContent[] {
const tag = el.localName.toLowerCase(); const tag = el.localName.toLowerCase();
switch (tag) { switch (tag) {
case "p": case "p":
@@ -192,15 +229,15 @@ function blockFromElement(el: Element): JSONContent[] {
return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : []; return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : [];
} }
case "blockquote": { case "blockquote": {
const inner = blocksFrom(el); const inner = blocksFrom(el, depth + 1);
return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }]; return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }];
} }
case "ul": { case "ul": {
const items = listItems(el); const items = listItems(el, depth);
return items.length ? [{ type: "bulletList", content: items }] : []; return items.length ? [{ type: "bulletList", content: items }] : [];
} }
case "ol": { case "ol": {
const items = listItems(el); const items = listItems(el, depth);
return items.length ? [{ type: "orderedList", content: items }] : []; return items.length ? [{ type: "orderedList", content: items }] : [];
} }
case "hr": case "hr":
@@ -209,22 +246,27 @@ function blockFromElement(el: Element): JSONContent[] {
return figureFrom(el); return figureFrom(el);
case "img": case "img":
return imgFigure(el); return imgFigure(el);
case "svg":
return svgFigure(el);
case "table":
return tableBlocks(el);
case "script": case "script":
case "style": case "style":
return []; return [];
default: default:
return hasBlockChild(el) ? blocksFrom(el) : paragraphAndFigures(el); return hasBlockChild(el) ? blocksFrom(el, depth + 1) : paragraphAndFigures(el);
} }
} }
function blocksFrom(parent: Node): JSONContent[] { function blocksFrom(parent: Node, depth = 0): JSONContent[] {
if (depth > 64) return [];
const out: JSONContent[] = []; const out: JSONContent[] = [];
parent.childNodes.forEach((child) => { parent.childNodes.forEach((child) => {
if (child.nodeType === 3) { if (child.nodeType === 3) {
const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim(); const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim();
if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] }); if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] });
} else if (child.nodeType === 1) { } else if (child.nodeType === 1) {
out.push(...blockFromElement(child as Element)); out.push(...blockFromElement(child as Element, depth));
} }
}); });
return out; return out;
@@ -234,6 +276,41 @@ function headingText(node: JSONContent): string {
return (node.content ?? []).map((n) => n.text ?? "").join("").trim(); return (node.content ?? []).map((n) => n.text ?? "").join("").trim();
} }
function isbnChecksum(s: string): boolean {
if (s.length === 10) {
let sum = 0;
for (let i = 0; i < 10; i++) {
const v = s[i] === "X" ? 10 : Number(s[i]);
if (Number.isNaN(v) || (s[i] === "X" && i !== 9)) return false;
sum += v * (10 - i);
}
return sum % 11 === 0;
}
if (s.length === 13 && /^\d{13}$/.test(s)) {
let sum = 0;
for (let i = 0; i < 13; i++) sum += Number(s[i]) * (i % 2 === 0 ? 1 : 3);
return sum % 10 === 0;
}
return false;
}
function findIsbn(opf: Document): string {
const ids = Array.from(opf.getElementsByTagName("*")).filter((el) => el.localName === "identifier");
const ranked = ids
.map((el) => ({
text: el.textContent ?? "",
tagged:
/isbn/i.test(el.getAttribute("opf:scheme") || el.getAttribute("scheme") || "") ||
/urn:isbn:/i.test(el.textContent ?? ""),
}))
.sort((a, b) => Number(b.tagged) - Number(a.tagged));
for (const { text } of ranked) {
const cleaned = text.replace(/[^0-9Xx]/g, "").toUpperCase();
if (isbnChecksum(cleaned)) return cleaned;
}
return "";
}
interface ManifestItem { interface ManifestItem {
href: string; href: string;
mediaType: string; mediaType: string;
@@ -317,6 +394,11 @@ function readNavTitles(
return titles; return titles;
} }
function fileToDataUri(file: RawFile, mime: string): string {
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
return `data:${mime};base64,${data}`;
}
function resolveImages( function resolveImages(
doc: Document, doc: Document,
baseDir: string, baseDir: string,
@@ -335,12 +417,29 @@ function resolveImages(
img.removeAttribute("src"); img.removeAttribute("src");
return; return;
} }
const mime = mimeByPath.get(resolved) || mimeFromExt(resolved); img.setAttribute("src", fileToDataUri(file, mimeByPath.get(resolved) || mimeFromExt(resolved)));
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
img.setAttribute("src", `data:${mime};base64,${data}`);
}); });
} }
function findCover(
opf: Document,
manifest: Map<string, ManifestItem>,
byPath: Map<string, RawFile>,
): Cover {
let item = Array.from(manifest.values()).find((m) => m.properties.split(/\s+/).includes("cover-image"));
if (!item) {
const metaCover = Array.from(opf.getElementsByTagName("*")).find(
(el) => el.localName === "meta" && el.getAttribute("name") === "cover",
);
const id = metaCover?.getAttribute("content");
if (id) item = manifest.get(id);
}
if (!item) return createCover();
const file = byPath.get(normalize(item.href));
if (!file) return createCover();
return { ...createCover(), kind: "image", image: fileToDataUri(file, item.mediaType || mimeFromExt(item.href)) };
}
function buildChapter( function buildChapter(
file: RawFile, file: RawFile,
baseDir: string, baseDir: string,
@@ -394,18 +493,21 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
spine.forEach((item, i) => { spine.forEach((item, i) => {
const file = byPath.get(normalize(item.href)); const file = byPath.get(normalize(item.href));
if (!file) return; if (!file) return;
try {
chapters.push( chapters.push(
buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest), buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest),
); );
} catch {
return;
}
}); });
if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found."); if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found.");
const identifier = localText(opf, "identifier");
const metadata: BookMetadata = { const metadata: BookMetadata = {
title: localText(opf, "title") || fallbackName, title: localText(opf, "title") || fallbackName,
subtitle: "", subtitle: "",
author: localText(opf, "creator"), author: localText(opf, "creator"),
isbn: /\d{9,13}/.test(identifier) ? identifier.replace(/[^0-9Xx]/g, "") : "", isbn: findIsbn(opf),
language: localText(opf, "language") || "en", language: localText(opf, "language") || "en",
}; };
@@ -414,8 +516,8 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
id: crypto.randomUUID(), id: crypto.randomUUID(),
metadata, metadata,
theme: "quiet-press", theme: "quiet-press",
settings: { trim: "6x9", bleed: true }, settings: { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS },
cover: createCover(), cover: findCover(opf, manifest, byPath),
chapters, chapters,
}; };
} }
+27 -2
View File
@@ -7,7 +7,32 @@ export interface ImageInput {
data: string; data: string;
} }
export async function compilePdf(source: string, images: ImageInput[] = []): Promise<Uint8Array> { export interface PdfFonts {
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images }); bundled: string[];
system: string[];
}
export async function compilePdf(
source: string,
images: ImageInput[] = [],
emitWarnings = false,
fonts: PdfFonts = { bundled: [], system: [] },
): Promise<Uint8Array> {
const buffer = await invoke<ArrayBuffer>("compile_pdf", {
source,
images,
emitWarnings,
bundledFonts: fonts.bundled,
systemFonts: fonts.system,
});
return new Uint8Array(buffer); return new Uint8Array(buffer);
} }
export async function listSystemFonts(): Promise<string[]> {
if (!isDesktop) return [];
try {
return await invoke<string[]>("list_system_fonts");
} catch {
return [];
}
}
+27 -2
View File
@@ -1,11 +1,12 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { isDesktop } from "./ipc"; import { isDesktop } from "./ipc";
import { type Book, createBook, starterBook } from "./model/book"; import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book";
export interface BookSummary { export interface BookSummary {
id: string; id: string;
title: string; title: string;
author: string; author: string;
corrupt?: boolean;
} }
export async function listBooks(): Promise<BookSummary[]> { export async function listBooks(): Promise<BookSummary[]> {
@@ -15,7 +16,16 @@ export async function listBooks(): Promise<BookSummary[]> {
export async function loadBook(id: string): Promise<Book> { export async function loadBook(id: string): Promise<Book> {
const contents = await invoke<string>("load_book", { id }); const contents = await invoke<string>("load_book", { id });
return JSON.parse(contents) as Book; let book: Book;
try {
book = JSON.parse(contents) as Book;
} catch {
throw new Error("the book file is corrupt or unreadable");
}
if (schemaVersion(book.schema) > SCHEMA_VERSION) {
throw new Error("this book was made with a newer version of Margin; update the app to open it");
}
return book;
} }
export async function saveBook(book: Book): Promise<void> { export async function saveBook(book: Book): Promise<void> {
@@ -35,3 +45,18 @@ export function newBook(): Book {
export function exampleBook(): Book { export function exampleBook(): Book {
return starterBook(); return starterBook();
} }
export async function createAndOpenBook(
open: (book: Book) => void,
onError?: (message: string) => void,
): Promise<void> {
const book = newBook();
if (isDesktop) {
try {
await saveBook(book);
} catch (e) {
onError?.(`Could not create book: ${e}`);
}
}
open(book);
}
+21 -3
View File
@@ -1,4 +1,5 @@
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { type BookFonts, DEFAULT_FONTS } from "./fonts";
export type TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5"; export type TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5";
export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top"; export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top";
@@ -24,6 +25,7 @@ export interface BookMetadata {
export interface BookSettings { export interface BookSettings {
trim: TrimSize; trim: TrimSize;
bleed: boolean; bleed: boolean;
fonts: BookFonts;
} }
export type CoverKind = "default" | "image"; export type CoverKind = "default" | "image";
@@ -115,6 +117,14 @@ export interface Book {
chapters: Chapter[]; chapters: Chapter[];
} }
export const SCHEMA_VERSION = 1;
export function schemaVersion(schema: unknown): number {
if (typeof schema !== "string") return 0;
const match = /^margin\/(\d+)$/.exec(schema);
return match ? Number(match[1]) : 0;
}
function paragraph(text: string): JSONContent { function paragraph(text: string): JSONContent {
return { type: "paragraph", content: [{ type: "text", text }] }; return { type: "paragraph", content: [{ type: "text", text }] };
} }
@@ -140,11 +150,19 @@ export function createCover(): Cover {
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink }; return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
} }
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS };
export function normalizeBook(book: Book): Book { export function normalizeBook(book: Book): Book {
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
return { return {
...book, ...book,
metadata: { ...DEFAULT_METADATA, ...book.metadata },
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(), cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
chapters: book.chapters.map((c) => (c.updatedAt ? c : { ...c, updatedAt: Date.now() })), chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
c.updatedAt ? c : { ...c, updatedAt: Date.now() },
),
}; };
} }
@@ -152,9 +170,9 @@ export function createBook(): Book {
return { return {
schema: "margin/1", schema: "margin/1",
id: crypto.randomUUID(), id: crypto.randomUUID(),
metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" }, metadata: { ...DEFAULT_METADATA },
theme: "quiet-press", theme: "quiet-press",
settings: { trim: "6x9", bleed: true }, settings: { ...DEFAULT_SETTINGS },
cover: createCover(), cover: createCover(),
chapters: [createChapter("Chapter One")], chapters: [createChapter("Chapter One")],
}; };
+106
View File
@@ -0,0 +1,106 @@
export type FontCategory = "serif" | "sans" | "display";
export interface BundledFont {
id: string;
label: string;
family: string;
category: FontCategory;
regular: string;
italic?: string;
weight: string;
}
export const BUNDLED_FONTS: BundledFont[] = [
{ id: "literata", label: "Literata", family: "Literata", category: "serif", regular: "Literata-VF.ttf", italic: "Literata-Italic-VF.ttf", weight: "200 900" },
{ id: "eb-garamond", label: "EB Garamond", family: "EB Garamond", category: "serif", regular: "EBGaramond-VF.ttf", italic: "EBGaramond-Italic-VF.ttf", weight: "400 800" },
{ id: "lora", label: "Lora", family: "Lora", category: "serif", regular: "Lora-VF.ttf", italic: "Lora-Italic-VF.ttf", weight: "400 700" },
{ id: "source-serif", label: "Source Serif 4", family: "Source Serif 4", category: "serif", regular: "SourceSerif4-VF.ttf", italic: "SourceSerif4-Italic-VF.ttf", weight: "200 900" },
{ id: "fraunces", label: "Fraunces", family: "Fraunces", category: "display", regular: "Fraunces-VF.ttf", italic: "Fraunces-Italic-VF.ttf", weight: "100 900" },
{ id: "hanken", label: "Hanken Grotesk", family: "Hanken Grotesk", category: "sans", regular: "HankenGrotesk-VF.ttf", italic: "HankenGrotesk-Italic-VF.ttf", weight: "100 900" },
];
export type FontRef =
| { kind: "bundled"; id: string }
| { kind: "system"; family: string };
export interface BookFonts {
body: FontRef;
heading: FontRef;
}
export interface FontPairing {
id: string;
label: string;
body: FontRef;
heading: FontRef;
}
const bundled = (id: string): FontRef => ({ kind: "bundled", id });
export const FONT_PAIRINGS: FontPairing[] = [
{ id: "quiet-press", label: "Quiet Press", body: bundled("literata"), heading: bundled("literata") },
{ id: "classic", label: "Classic", body: bundled("eb-garamond"), heading: bundled("eb-garamond") },
{ id: "editorial", label: "Editorial", body: bundled("source-serif"), heading: bundled("fraunces") },
{ id: "modern", label: "Modern", body: bundled("lora"), heading: bundled("hanken") },
{ id: "contrast", label: "Contrast", body: bundled("literata"), heading: bundled("hanken") },
];
export const DEFAULT_FONTS: BookFonts = { body: bundled("literata"), heading: bundled("literata") };
const SERIF_FALLBACK = `Georgia, "Times New Roman", serif`;
const SANS_FALLBACK = `ui-sans-serif, system-ui, -apple-system, sans-serif`;
export function bundledFont(id: string): BundledFont | undefined {
return BUNDLED_FONTS.find((f) => f.id === id);
}
export function encodeRef(ref: FontRef): string {
return ref.kind === "bundled" ? `b:${ref.id}` : `s:${ref.family}`;
}
export function decodeRef(value: string): FontRef {
return value.startsWith("s:") ? { kind: "system", family: value.slice(2) } : { kind: "bundled", id: value.slice(2) };
}
export function fontFamilyName(ref: FontRef): string {
if (ref.kind === "system") return ref.family;
return bundledFont(ref.id)?.family ?? "Literata";
}
export function fontLabel(ref: FontRef): string {
if (ref.kind === "system") return ref.family;
return bundledFont(ref.id)?.label ?? ref.id;
}
export function fontStack(ref: FontRef): string {
if (ref.kind === "bundled") {
const font = bundledFont(ref.id);
if (!font) return SERIF_FALLBACK;
return `"${font.family}", ${font.category === "sans" ? SANS_FALLBACK : SERIF_FALLBACK}`;
}
return `"${ref.family}", ${SERIF_FALLBACK}`;
}
export function refsEqual(a: FontRef, b: FontRef): boolean {
if (a.kind !== b.kind) return false;
return a.kind === "bundled" ? a.id === (b as { id: string }).id : a.family === (b as { family: string }).family;
}
export function pairingFor(fonts: BookFonts): string | null {
const match = FONT_PAIRINGS.find((p) => refsEqual(p.body, fonts.body) && refsEqual(p.heading, fonts.heading));
return match?.id ?? null;
}
export function fontsUsed(fonts: BookFonts): { bundled: BundledFont[]; system: string[] } {
const usedBundled = new Map<string, BundledFont>();
const usedSystem = new Set<string>();
for (const ref of [fonts.body, fonts.heading]) {
if (ref.kind === "bundled") {
const font = bundledFont(ref.id);
if (font) usedBundled.set(font.id, font);
} else {
usedSystem.add(ref.family);
}
}
return { bundled: [...usedBundled.values()], system: [...usedSystem] };
}
+156
View File
@@ -0,0 +1,156 @@
import { create } from "zustand";
import { openUrl } from "@tauri-apps/plugin-opener";
import {
type BackupStatus,
gdriveBackup,
gdriveConnect,
gdriveDisconnect,
gdriveRestore,
gdriveStatus,
} from "../backup";
import { isDesktop } from "../ipc";
import { useBook } from "./useBook";
type Phase = "idle" | "connecting" | "working" | "error";
interface BackupState {
connected: boolean;
email: string | null;
lastBackup: number | null;
pending: boolean;
phase: Phase;
error: string | null;
authUrl: string | null;
settingsOpen: boolean;
restoreNonce: number;
resolveConnect: ((ok: boolean) => void) | null;
apply: (status: BackupStatus) => void;
refresh: () => Promise<void>;
connect: () => Promise<boolean>;
cancelConnect: () => void;
handleAuthEvent: (ok: boolean, error: string | null) => Promise<void>;
openAuthUrl: () => void;
copyAuthUrl: () => Promise<void>;
disconnect: () => Promise<void>;
backup: (silent?: boolean) => Promise<void>;
restore: () => Promise<void>;
restoreFromDrive: () => Promise<void>;
openSettings: () => void;
closeSettings: () => void;
}
function notify(message: string) {
useBook.getState().setNotice(message);
}
export const useBackup = create<BackupState>((set, get) => ({
connected: false,
email: null,
lastBackup: null,
pending: false,
phase: "idle",
error: null,
authUrl: null,
settingsOpen: false,
restoreNonce: 0,
resolveConnect: null,
apply: (status) =>
set({
connected: status.connected,
email: status.email,
lastBackup: status.lastBackup,
pending: status.pending,
}),
refresh: async () => {
if (!isDesktop) return;
try {
get().apply(await gdriveStatus());
} catch {}
},
connect: () =>
new Promise<boolean>((resolve) => {
get().resolveConnect?.(false);
set({ phase: "connecting", error: null, authUrl: null, resolveConnect: resolve });
gdriveConnect()
.then((url) => set({ authUrl: url }))
.catch((e) => {
set({ phase: "error", error: String(e), resolveConnect: null });
notify(`Could not connect: ${e}`);
resolve(false);
});
}),
cancelConnect: () => {
const resolve = get().resolveConnect;
set({ phase: "idle", authUrl: null, resolveConnect: null });
resolve?.(false);
},
handleAuthEvent: async (ok, error) => {
if (get().phase !== "connecting") return;
const resolve = get().resolveConnect;
if (ok) {
await get().refresh();
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
notify("Connected to Google Drive");
} else {
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
notify(`Could not connect: ${error ?? "authorization failed"}`);
}
resolve?.(ok);
},
openAuthUrl: () => {
const url = get().authUrl;
if (url) openUrl(url).catch(() => {});
},
copyAuthUrl: async () => {
const url = get().authUrl;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
notify("Authorization link copied");
} catch {
notify("Could not copy the link");
}
},
disconnect: async () => {
set({ phase: "working", error: null });
try {
get().apply(await gdriveDisconnect());
set({ phase: "idle" });
notify("Disconnected from Google Drive");
} catch (e) {
set({ phase: "error", error: String(e) });
notify(`Could not disconnect: ${e}`);
}
},
backup: async (silent = false) => {
if (!get().connected || get().phase === "working") return;
set({ phase: "working", error: null });
try {
const outcome = await gdriveBackup();
get().apply(outcome);
set({ phase: "idle" });
if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up");
} catch (e) {
set({ phase: "error", error: String(e) });
if (!silent) notify(`Backup failed: ${e}`);
}
},
restore: async () => {
set({ phase: "working", error: null });
try {
const result = await gdriveRestore();
await get().refresh();
set((s) => ({ phase: "idle", restoreNonce: s.restoreNonce + 1 }));
notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive");
} catch (e) {
set({ phase: "error", error: String(e) });
notify(`Restore failed: ${e}`);
}
},
restoreFromDrive: async () => {
const ok = get().connected || (await get().connect());
if (ok) await get().restore();
},
openSettings: () => set({ settingsOpen: true }),
closeSettings: () => set({ settingsOpen: false }),
}));
+33 -4
View File
@@ -12,6 +12,9 @@ import {
createPage, createPage,
normalizeBook, normalizeBook,
} from "../model/book"; } from "../model/book";
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
import { saveBook } from "../library";
import { applyBookFonts, resetBookFonts } from "../book-style";
export const COVER_ID = "__cover__"; export const COVER_ID = "__cover__";
@@ -40,7 +43,11 @@ interface BookState {
markSaved: () => void; markSaved: () => void;
} }
export const useBook = create<BookState>((set) => ({ function flushOutgoing(book: Book | null, dirty: boolean): void {
if (book && dirty) saveBook(book).catch(() => {});
}
export const useBook = create<BookState>((set, get) => ({
book: null, book: null,
activeChapterId: "", activeChapterId: "",
dirty: false, dirty: false,
@@ -49,10 +56,19 @@ export const useBook = create<BookState>((set) => ({
setExporting: (label) => set({ exporting: label }), setExporting: (label) => set({ exporting: label }),
setNotice: (message) => set({ notice: message }), setNotice: (message) => set({ notice: message }),
openBook: (book) => { openBook: (book) => {
flushOutgoing(get().book, get().dirty);
const normalized = normalizeBook(book); const normalized = normalizeBook(book);
set({ book: normalized, activeChapterId: normalized.chapters[0]?.id ?? "", dirty: false }); const saved = loadActiveChapter(normalized.id);
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
applyBookFonts(normalized.settings.fonts);
set({ book: normalized, activeChapterId, dirty: false });
},
closeBook: () => {
flushOutgoing(get().book, get().dirty);
resetBookFonts();
set({ book: null, activeChapterId: "", dirty: false });
}, },
closeBook: () => set({ book: null, activeChapterId: "", dirty: false }),
setActiveChapter: (id) => set({ activeChapterId: id }), setActiveChapter: (id) => set({ activeChapterId: id }),
setChapterContent: (id, content) => setChapterContent: (id, content) =>
set((s) => set((s) =>
@@ -127,8 +143,21 @@ export const useBook = create<BookState>((set) => ({
setMetadata: (patch) => setMetadata: (patch) =>
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})), set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
setSettings: (patch) => setSettings: (patch) =>
set((s) => (s.book ? { dirty: true, book: { ...s.book, settings: { ...s.book.settings, ...patch } } } : {})), set((s) => {
if (!s.book) return {};
const book = { ...s.book, settings: { ...s.book.settings, ...patch } };
if (patch.fonts) applyBookFonts(book.settings.fonts);
return { dirty: true, book };
}),
setCover: (patch) => setCover: (patch) =>
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})), set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
markSaved: () => set({ dirty: false }), markSaved: () => set({ dirty: false }),
})); }));
let lastActive = "";
useBook.subscribe((s) => {
if (s.book && s.activeChapterId && s.activeChapterId !== lastActive) {
lastActive = s.activeChapterId;
saveActiveChapter(s.book.id, s.activeChapterId);
}
});
+15
View File
@@ -0,0 +1,15 @@
import { create } from "zustand";
import { type PreviewMode, initialPreviewMode, persistPreviewMode } from "../devices";
interface PreviewState {
mode: PreviewMode;
setMode: (mode: PreviewMode) => void;
}
export const usePreviewMode = create<PreviewState>((set) => ({
mode: initialPreviewMode(),
setMode: (mode) => {
persistPreviewMode(mode);
set({ mode });
},
}));
+465 -3
View File
@@ -427,6 +427,7 @@ body.resizing {
} }
.chapter-title { .chapter-title {
font-family: var(--font-heading);
font-weight: 500; font-weight: 500;
font-size: 31px; font-size: 31px;
line-height: 1.16; line-height: 1.16;
@@ -434,6 +435,10 @@ body.resizing {
margin: 15px 0 0; margin: 15px 0 0;
} }
.prose :is(h1, h2, h3) {
font-family: var(--font-heading);
}
.prose p { .prose p {
font-size: 19px; font-size: 19px;
line-height: 1.68; line-height: 1.68;
@@ -565,11 +570,15 @@ body.resizing {
} }
.page .p-title { .page .p-title {
font-family: var(--font-book); font-family: var(--font-heading);
font-size: 12px; font-size: 12px;
margin-top: 5px; margin-top: 5px;
} }
.page :is(h2, h3) {
font-family: var(--font-heading);
}
.page p { .page p {
font-family: var(--font-book); font-family: var(--font-book);
font-size: 6.6px; font-size: 6.6px;
@@ -620,7 +629,7 @@ body.resizing {
border: 0; border: 0;
background: transparent; background: transparent;
text-align: center; text-align: center;
font-family: var(--font-book); font-family: var(--font-heading);
font-weight: 500; font-weight: 500;
font-size: 31px; font-size: 31px;
line-height: 1.16; line-height: 1.16;
@@ -660,6 +669,87 @@ body.resizing {
accent-color: var(--ink-soft); accent-color: var(--ink-soft);
} }
.font-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.font-preset {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 9px 12px;
min-width: 70px;
border: 1px solid var(--line-strong);
border-radius: var(--r-md);
background: var(--raised);
cursor: pointer;
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
}
.font-preset:hover {
border-color: var(--ink-faint);
}
.font-preset[data-on="true"] {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.font-preset-demo {
font-size: 23px;
line-height: 1;
color: var(--ink);
}
.font-preset-name {
font-family: var(--font-ui);
font-size: var(--t-1);
color: var(--ink-soft);
}
.font-advanced {
align-self: flex-start;
margin-top: 4px;
padding: 0;
background: none;
border: 0;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--accent);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.font-note {
margin: 0;
font-family: var(--font-ui);
font-size: var(--t-1);
line-height: 1.5;
color: var(--ink-faint);
}
.font-sample {
padding: 15px 17px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--paper);
color: var(--ink);
font-size: 15px;
line-height: 1.6;
}
.font-sample-title {
display: block;
font-size: 21px;
font-weight: 500;
line-height: 1.2;
margin-bottom: 6px;
}
.editor-host, .editor-host,
.ProseMirror { .ProseMirror {
outline: none; outline: none;
@@ -1167,6 +1257,10 @@ body.resizing {
.library-head { .library-head {
height: 44px; height: 44px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 14px;
} }
.shelf { .shelf {
@@ -1209,6 +1303,16 @@ body.resizing {
box-shadow: 0 4px 10px rgba(35, 32, 27, 0.1), 0 20px 38px rgba(35, 32, 27, 0.13); box-shadow: 0 4px 10px rgba(35, 32, 27, 0.1), 0 20px 38px rgba(35, 32, 27, 0.13);
} }
.card-corrupt {
cursor: default;
opacity: 0.65;
border-style: dashed;
}
.card-corrupt:hover {
transform: none;
}
.card-title { .card-title {
font-family: var(--font-book); font-family: var(--font-book);
font-size: 20px; font-size: 20px;
@@ -1422,7 +1526,7 @@ body.resizing {
} }
.cover-title { .cover-title {
font-family: var(--font-book); font-family: var(--font-heading);
font-weight: 500; font-weight: 500;
font-size: 9.5cqw; font-size: 9.5cqw;
line-height: 1.08; line-height: 1.08;
@@ -1992,3 +2096,361 @@ body.resizing {
background: var(--accent-wash); background: var(--accent-wash);
color: var(--ink); color: var(--ink);
} }
.device-stage {
max-width: 420px;
margin: 0 auto;
padding-block: 6px;
container-type: inline-size;
}
.device-frame {
--dv-scale: calc(100cqi / 380);
width: 100%;
padding: calc(var(--dv-bezel-w) * var(--dv-scale));
background: var(--dv-bezel);
border-radius: calc(var(--dv-radius) * var(--dv-scale));
box-shadow: var(--shadow-page);
}
.device-screen {
aspect-ratio: var(--dv-aspect);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--dv-bg);
color: var(--dv-ink);
border-radius: calc(max(0px, (var(--dv-radius) - var(--dv-bezel-w)) * var(--dv-scale)));
}
.device-statusbar {
flex: none;
height: calc(30 * var(--dv-scale));
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 calc(18 * var(--dv-scale));
font-family: var(--font-ui);
font-size: calc(11 * var(--dv-scale));
font-weight: 600;
color: var(--dv-ink);
}
.device-clock {
letter-spacing: 0.01em;
}
.device-batt {
position: relative;
flex: none;
width: calc(22 * var(--dv-scale));
height: calc(11 * var(--dv-scale));
border: calc(1.4 * var(--dv-scale)) solid var(--dv-ink);
border-radius: calc(3 * var(--dv-scale));
opacity: 0.7;
}
.device-batt::before {
content: "";
position: absolute;
top: calc(1.5 * var(--dv-scale));
bottom: calc(1.5 * var(--dv-scale));
left: calc(1.5 * var(--dv-scale));
width: 55%;
background: var(--dv-ink);
border-radius: 1px;
}
.device-batt::after {
content: "";
position: absolute;
right: calc(-3 * var(--dv-scale));
top: 30%;
height: 40%;
width: calc(2 * var(--dv-scale));
border-radius: 1px;
background: var(--dv-ink);
}
.device-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: calc(var(--dv-my) * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale));
font-family: var(--font-book);
font-size: calc(var(--dv-fs) * var(--dv-scale));
line-height: var(--dv-lh);
}
.device-frame[data-kind="eink"] .device-content::-webkit-scrollbar {
display: none;
}
.device-opener {
text-align: center;
margin-bottom: calc(20 * var(--dv-scale));
}
.device-eyebrow {
font-family: var(--font-ui);
font-size: calc(10 * var(--dv-scale));
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
opacity: 0.5;
margin-bottom: calc(8 * var(--dv-scale));
}
.device-title {
font-family: var(--font-heading);
font-weight: 500;
font-size: calc(24 * var(--dv-scale));
line-height: 1.2;
letter-spacing: -0.01em;
}
.device-body p {
font-family: var(--font-book);
hyphens: auto;
margin: 0 0 0.8em;
color: var(--dv-ink);
}
.device-body p[data-indent="true"] {
text-indent: 1.2em;
}
.device-frame[data-kind="eink"] .device-body p {
text-align: justify;
}
.device-frame[data-kind="phone"] .device-body p,
.device-frame[data-kind="tablet"] .device-body p {
text-align: left;
}
.device-body h1,
.device-body h2,
.device-body h3 {
font-family: var(--font-heading);
font-weight: 600;
line-height: 1.25;
margin: 1.2em 0 0.5em;
}
.device-body blockquote {
margin: 0 0 0.8em;
padding-left: 1em;
border-left: 2px solid currentColor;
font-style: italic;
opacity: 0.85;
}
.device-body hr {
border: none;
border-top: 1px solid currentColor;
width: calc(40 * var(--dv-scale));
margin: calc(16 * var(--dv-scale)) auto;
opacity: 0.3;
}
.device-body img {
display: block;
max-width: 100%;
height: auto;
margin: 0.8em auto;
}
.device-footer {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(10 * var(--dv-scale));
padding: calc(8 * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale));
font-family: var(--font-ui);
font-size: calc(11 * var(--dv-scale));
color: var(--dv-ink);
opacity: 0.55;
}
.device-foot-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.device-content .cover-art {
width: 100%;
margin: auto;
box-shadow: none;
border: none;
border-radius: 0;
}
/* Google Drive backup */
.backup-btn[data-state="pending"] {
color: var(--accent);
}
.backup-btn[data-state="error"] {
color: var(--danger);
}
.backup-btn[data-state="working"] svg {
animation: backup-spin 0.9s linear infinite;
}
@keyframes backup-spin {
to {
transform: rotate(360deg);
}
}
.library-restore {
display: flex;
justify-content: center;
padding: 0 44px 60px;
}
.restore-link {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--ink-faint);
font-family: var(--font-ui);
font-size: var(--t-2);
transition: color 120ms var(--ease);
}
.restore-link:hover:not(:disabled) {
color: var(--accent);
}
.restore-link:disabled {
opacity: 0.6;
cursor: default;
}
.backup-intro,
.backup-connecting {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.backup-intro p,
.backup-connecting p {
margin: 0;
color: var(--ink-soft);
font-family: var(--font-ui);
font-size: var(--t-3);
line-height: 1.55;
}
.backup-text-btn {
color: var(--ink-faint);
font-family: var(--font-ui);
font-size: var(--t-2);
transition: color 120ms var(--ease);
}
.backup-text-btn:hover {
color: var(--accent);
}
.backup-waiting {
display: flex;
align-items: center;
gap: 9px;
font-family: var(--font-ui);
font-size: var(--t-3);
color: var(--ink);
}
.backup-waiting svg {
color: var(--ink-soft);
animation: backup-spin 0.9s linear infinite;
}
.backup-links {
display: flex;
align-items: center;
gap: 10px;
}
.backup-link-sep {
color: var(--line-strong);
}
.backup-link-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: 2px;
}
.backup-account {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--line);
}
.backup-account-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.backup-email {
font-family: var(--font-ui);
font-size: var(--t-3);
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.backup-status-row {
display: flex;
align-items: center;
gap: 9px;
margin: 18px 0;
font-family: var(--font-ui);
font-size: var(--t-3);
color: var(--ink-soft);
}
.backup-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
}
.backup-dot.is-synced {
background: var(--ink-faint);
}
.backup-dot.is-pending {
background: var(--accent);
}
.backup-actions {
display: flex;
gap: 10px;
}
.backup-note {
margin: 18px 0 0;
color: var(--ink-faint);
font-family: var(--font-ui);
font-size: var(--t-2);
line-height: 1.5;
}
+64
View File
@@ -29,3 +29,67 @@
font-style: italic; font-style: italic;
font-display: block; font-display: block;
} }
@font-face {
font-family: "EB Garamond";
src: url("/fonts/EBGaramond-VF.ttf") format("truetype-variations");
font-weight: 400 800;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "EB Garamond";
src: url("/fonts/EBGaramond-Italic-VF.ttf") format("truetype-variations");
font-weight: 400 800;
font-style: italic;
font-display: block;
}
@font-face {
font-family: "Lora";
src: url("/fonts/Lora-VF.ttf") format("truetype-variations");
font-weight: 400 700;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Lora";
src: url("/fonts/Lora-Italic-VF.ttf") format("truetype-variations");
font-weight: 400 700;
font-style: italic;
font-display: block;
}
@font-face {
font-family: "Source Serif 4";
src: url("/fonts/SourceSerif4-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Source Serif 4";
src: url("/fonts/SourceSerif4-Italic-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: italic;
font-display: block;
}
@font-face {
font-family: "Fraunces";
src: url("/fonts/Fraunces-VF.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Fraunces";
src: url("/fonts/Fraunces-Italic-VF.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: italic;
font-display: block;
}
+1
View File
@@ -1,6 +1,7 @@
:root { :root {
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; --font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-book: "Literata", Georgia, "Times New Roman", serif; --font-book: "Literata", Georgia, "Times New Roman", serif;
--font-heading: "Literata", Georgia, "Times New Roman", serif;
--r-sm: 5px; --r-sm: 5px;
--r-md: 8px; --r-md: 8px;
+1 -1
View File
@@ -26,7 +26,7 @@ export default defineConfig(async () => ({
: undefined, : undefined,
watch: { watch: {
// 3. tell Vite to ignore watching `src-tauri` // 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"], ignored: ["**/src-tauri/**", "**/website/**"],
}, },
}, },
})); }));
+1 -1
View File
@@ -3,5 +3,5 @@ import { defineConfig } from "astro/config";
// The canonical URL is only used for absolute links (OG tags, sitemap). // The canonical URL is only used for absolute links (OG tags, sitemap).
// Change it to the real domain when the site goes live. // Change it to the real domain when the site goes live.
export default defineConfig({ export default defineConfig({
site: "https://margin.studio", site: "https://margin.73ai.org",
}); });
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview" "preview": "astro preview",
"deploy": "astro build && npx wrangler pages deploy dist --project-name=margin --commit-dirty=true"
}, },
"dependencies": { "dependencies": {
"astro": "^5.6.1" "astro": "^5.6.1"
+276 -87
View File
@@ -1,37 +1,70 @@
--- ---
// A faithful CSS mock-up of the Margin desktop app: chapter sidebar, the // A faithful CSS mock-up of the Margin desktop app: the All-books sidebar with
// writing surface set in Literata, and the live PDF preview dock. The editor // front-matter / chapter sections, the writing surface set in Literata with live
// shows live proofing underlines; the PDF dock shows the clean typeset page. // proofing, and the live PDF preview dock with its trim selector.
--- ---
<div class="win" role="img" aria-label="The Margin app: a chapter sidebar, a writing surface with live proofing, and a live PDF page preview."> <div class="win" role="img" aria-label="The Margin app: a chapter sidebar, a writing surface with live proofing, and a live PDF page preview.">
<div class="win__bar"> <div class="win__bar">
<span class="dot"></span><span class="dot"></span><span class="dot"></span> <span class="win__chrome"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span>
<span class="win__title">The Lighthouse Keeper</span> <span class="win__title">The Lighthouse Keeper</span>
<span class="win__menu">Export ▾</span> <span class="win__actions">
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg></span>
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" /></svg></span>
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" /></svg></span>
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" /></svg></span>
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg></span>
<span class="ibtn is-on"><svg class="i" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
</span>
</div> </div>
<div class="win__body"> <div class="win__body">
<aside class="side"> <aside class="side">
<p class="side__label">Manuscript</p> <span class="side__brand">
<ul> <svg class="i" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>All books
<li>Title Page</li> </span>
<li>Dedication</li> <span class="side__cover">
<li class="is-active">1 · The Cliff</li> <svg class="i" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>Cover
<li>2 · A Light at Sea</li> </span>
<li>3 · Salt and Silence</li> <span class="side__add">
<li>4 · The Long Night</li> <svg class="i" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>Add page
</span>
<div class="side__scroll">
<div class="side__section">
<p class="side__label">Front matter</p>
<ul class="side__list">
<li class="row"><span class="grip"></span><span class="num"></span><span class="text"><span class="title">Title Page</span></span></li>
<li class="row"><span class="grip"></span><span class="num"></span><span class="text"><span class="title">Dedication</span></span></li>
</ul> </ul>
<p class="side__label">Book setup</p> </div>
<ul class="side__meta">
<li>Trim · 5 × 8 in</li> <div class="side__section">
<li>Language · English</li> <p class="side__label">Chapters</p>
<ul class="side__list">
<li class="row is-active">
<span class="grip"><svg class="i" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="num">1</span>
<span class="text">
<span class="title">The Cliff</span>
<span class="meta">Edited yesterday</span>
</span>
</li>
<li class="row"><span class="grip"></span><span class="num">2</span><span class="text"><span class="title">A Light at Sea</span></span></li>
<li class="row"><span class="grip"></span><span class="num">3</span><span class="text"><span class="title">Salt and Silence</span></span></li>
<li class="row"><span class="grip"></span><span class="num">4</span><span class="text"><span class="title">The Long Night</span></span></li>
</ul> </ul>
<span class="side__newchapter">
<svg class="i" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>New chapter
</span>
</div>
</div>
</aside> </aside>
<main class="page"> <main class="page">
<div class="page__sheet"> <div class="page__sheet">
<p class="chapno">Chapter One</p> <p class="chapno">Chapter 1</p>
<h4 class="chaptitle">The Cliff</h4> <h4 class="chaptitle">The Cliff</h4>
<p> <p>
The lighthouse had stood on the headland for ninety years, and in all that time it had The lighthouse had stood on the headland for ninety years, and in all that time it had
@@ -47,10 +80,17 @@
</main> </main>
<aside class="dock"> <aside class="dock">
<p class="dock__label">Live PDF · pg 7</p> <div class="dock__head">
<div class="dock__pdf"> <span class="dock__label">Preview</span>
<span class="rh">THE LIGHTHOUSE KEEPER</span> <span class="dock__trim">
<p class="pch">Chapter One</p> 5 × 8 in
<svg class="i" viewBox="0 0 24 24"><path d="M8 10l4-4 4 4M8 14l4 4 4-4" /></svg>
</span>
</div>
<div class="dock__pages">
<div class="dock__page">
<p class="pch">Chapter 1</p>
<p class="pdt">The Cliff</p> <p class="pdt">The Cliff</p>
<p class="pp pp--first"> <p class="pp pp--first">
The lighthouse had stood on the headland for ninety years, and in all that time it had The lighthouse had stood on the headland for ninety years, and in all that time it had
@@ -61,7 +101,15 @@
of habit, and watched the lamp turn its slow circle across the water. of habit, and watched the lamp turn its slow circle across the water.
</p> </p>
<p class="pp">The sea, tonight, was the colour of pewter.</p> <p class="pp">The sea, tonight, was the colour of pewter.</p>
<span class="pg">7</span> <span class="pg">1</span>
</div>
<div class="dock__page dock__page--peek">
<p class="pp pp--first">
Morning came grey and slow. She brewed coffee in the keeper's kitchen and listened to the
gulls argue over the rocks below.
</p>
<p class="pp">The radio crackled once, then fell silent.</p>
</div>
</div> </div>
</aside> </aside>
</div> </div>
@@ -76,115 +124,231 @@
box-shadow: var(--shadow-deep); box-shadow: var(--shadow-deep);
overflow: hidden; overflow: hidden;
} }
.i {
width: 15px;
height: 15px;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.win__bar { .win__bar {
display: flex; display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
gap: 8px; height: 44px;
height: 40px; padding-inline: 14px;
padding-inline: 16px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: var(--sidebar); background: var(--shell);
font-size: 0.78rem; }
color: var(--ink-faint); .win__chrome {
display: flex;
gap: 8px;
} }
.dot { .dot {
width: 11px; width: 11px;
height: 11px; height: 11px;
border-radius: 50%; border-radius: 50%;
} }
.win__bar .dot:nth-child(1) { .win__chrome .dot:nth-child(1) {
background: #ff5f57; background: #ff5f57;
} }
.win__bar .dot:nth-child(2) { .win__chrome .dot:nth-child(2) {
background: #febc2e; background: #febc2e;
} }
.win__bar .dot:nth-child(3) { .win__chrome .dot:nth-child(3) {
background: #28c840; background: #28c840;
} }
.win__title { .win__title {
margin-inline: auto; grid-column: 2;
font-weight: 500; font-size: 0.82rem;
} letter-spacing: 0.01em;
.win__menu {
color: var(--ink-soft); color: var(--ink-soft);
font-weight: 500;
} }
.win__actions {
grid-column: 3;
justify-self: end;
display: flex;
gap: 2px;
}
.ibtn {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
color: var(--ink-soft);
}
.ibtn.is-on {
color: var(--accent);
background: var(--accent-wash);
box-shadow: inset 0 0 0 1px var(--line-strong);
}
.win__body { .win__body {
display: grid; display: grid;
grid-template-columns: 172px 1fr 196px; grid-template-columns: 196px 1fr 200px;
min-height: 392px; min-height: 432px;
} }
.side { .side {
display: flex;
flex-direction: column;
background: var(--sidebar); background: var(--sidebar);
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
padding: 16px 12px; padding: 12px 10px;
font-size: 0.8rem; font-size: 0.8rem;
} }
.side__brand,
.side__cover,
.side__add,
.side__newchapter {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--r-sm);
color: var(--ink-soft);
}
.side__brand {
color: var(--ink-faint);
font-size: 0.74rem;
}
.side__cover {
color: var(--ink);
margin-top: 2px;
}
.side__add {
color: var(--ink-faint);
margin-bottom: 8px;
}
.side__scroll {
flex: 1;
}
.side__section + .side__section {
margin-top: 16px;
}
.side__label { .side__label {
font-size: 0.66rem; font-size: 0.62rem;
letter-spacing: 0.1em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-faint); color: var(--ink-faint);
font-weight: 650; font-weight: 650;
margin: 0 0 8px; margin: 0 0 6px;
padding-inline: 8px;
} }
.side ul { .side__list {
list-style: none; list-style: none;
margin: 0 0 18px; margin: 0;
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
} }
.side li { .row {
display: grid;
grid-template-columns: 14px 16px 1fr;
align-items: center;
gap: 4px;
padding: 5px 8px; padding: 5px 8px;
border-radius: var(--r-sm); border-radius: var(--r-sm);
color: var(--ink-soft); color: var(--ink-soft);
} }
.side li.is-active { .row .grip {
background: var(--accent-wash);
color: var(--ink);
font-weight: 600;
}
.side__meta li {
color: var(--ink-faint); color: var(--ink-faint);
font-size: 0.74rem; opacity: 0;
display: grid;
place-items: center;
}
.row .grip .i {
width: 13px;
height: 13px;
}
.row .num {
font-size: 0.72rem;
color: var(--ink-faint);
text-align: center;
}
.row .text {
display: flex;
flex-direction: column;
min-width: 0;
}
.row .title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row .meta {
font-size: 0.66rem;
color: var(--ink-faint);
margin-top: 1px;
}
.row.is-active {
background: var(--raised);
color: var(--ink);
box-shadow: 0 1px 2px rgba(35, 32, 27, 0.08), inset 0 0 0 1px var(--line);
}
.row.is-active .grip {
opacity: 1;
}
.row.is-active .num {
color: var(--ink-soft);
}
.side__newchapter {
color: var(--ink-faint);
font-size: 0.76rem;
margin-top: 4px;
} }
.page { .page {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 26px 18px; padding: 30px 18px;
overflow: hidden; overflow: hidden;
} }
.page__sheet { .page__sheet {
width: 100%; width: 100%;
max-width: 360px; max-width: 372px;
font-family: var(--font-book); font-family: var(--font-book);
font-size: 0.92rem; font-size: 0.92rem;
line-height: 1.62; line-height: 1.6;
color: var(--ink); color: var(--ink);
} }
.page__sheet p { .page__sheet p {
margin: 0 0 0.7em; margin: 0;
text-indent: 1.1em; text-indent: 1.3em;
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
}
.page__sheet p + p {
margin-top: 0.2em;
} }
.chapno { .chapno {
text-indent: 0 !important; text-indent: 0 !important;
text-align: center; text-align: center !important;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 0.62rem; font-size: 0.6rem;
letter-spacing: 0.18em; font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-faint); color: var(--ink-faint);
margin-bottom: 0.4em !important; margin: 0 0 0.5em !important;
} }
.chaptitle { .chaptitle {
text-indent: 0;
text-align: center; text-align: center;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 560; font-weight: 540;
margin: 0 0 1.1em; letter-spacing: -0.01em;
margin: 0 0 1.4em;
}
.chaptitle + p {
text-indent: 0;
} }
.u-spell { .u-spell {
text-decoration: wavy underline var(--danger); text-decoration: wavy underline var(--danger);
@@ -200,17 +364,45 @@
.dock { .dock {
background: var(--shell); background: var(--shell);
border-left: 1px solid var(--line); border-left: 1px solid var(--line);
padding: 16px 14px; padding: 14px 14px 0;
overflow: hidden;
}
.dock__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
} }
.dock__label { .dock__label {
font-size: 0.66rem; font-size: 0.62rem;
letter-spacing: 0.1em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-faint); color: var(--ink-faint);
font-weight: 650; font-weight: 650;
margin: 0 0 12px;
} }
.dock__pdf { .dock__trim {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-ui);
font-size: 0.66rem;
color: var(--ink-soft);
padding: 3px 5px 3px 8px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
}
.dock__trim .i {
width: 12px;
height: 12px;
color: var(--ink-faint);
}
.dock__pages {
display: flex;
flex-direction: column;
gap: 14px;
}
.dock__page {
position: relative; position: relative;
aspect-ratio: 5 / 8; aspect-ratio: 5 / 8;
background: #fcfbf7; background: #fcfbf7;
@@ -219,26 +411,18 @@
padding: 18px 16px 22px; padding: 18px 16px 22px;
overflow: hidden; overflow: hidden;
color: #2a2622; color: #2a2622;
flex: none;
} }
.dock__pdf .rh { .dock__page .pch {
display: block;
font-family: var(--font-ui);
font-size: 4.4px;
letter-spacing: 0.16em;
color: #b3ab9c;
text-align: center;
margin-bottom: 14px;
}
.dock__pdf .pch {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 4px; font-size: 4px;
letter-spacing: 0.22em; letter-spacing: 0.22em;
text-transform: uppercase; text-transform: uppercase;
color: #9b9484; color: #9b9484;
text-align: center; text-align: center;
margin: 0 0 4px; margin: 6px 0 4px;
} }
.dock__pdf .pdt { .dock__page .pdt {
font-family: var(--font-book); font-family: var(--font-book);
font-size: 9px; font-size: 9px;
font-weight: 560; font-weight: 560;
@@ -246,19 +430,21 @@
text-align: center; text-align: center;
margin: 0 0 11px; margin: 0 0 11px;
} }
.dock__pdf .pp { .dock__page .pp {
font-family: var(--font-book); font-family: var(--font-book);
font-size: 5.6px; font-size: 5.6px;
line-height: 1.62; line-height: 1.62;
text-align: justify; text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
text-indent: 1.3em; text-indent: 1.3em;
margin: 0 0 0.5em; margin: 0 0 0.5em;
color: #2a2622; color: #2a2622;
} }
.dock__pdf .pp--first { .dock__page .pp--first {
text-indent: 0; text-indent: 0;
} }
.dock__pdf .pg { .dock__page .pg {
position: absolute; position: absolute;
bottom: 9px; bottom: 9px;
left: 0; left: 0;
@@ -268,10 +454,13 @@
font-size: 5px; font-size: 5px;
color: #b3ab9c; color: #b3ab9c;
} }
.dock__page--peek {
padding-top: 18px;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.win__body { .win__body {
grid-template-columns: 1fr 168px; grid-template-columns: 1fr 180px;
} }
.side { .side {
display: none; display: none;
-6
View File
@@ -38,12 +38,6 @@ import OfferBadge from "./OfferBadge.astro";
justify-content: center; justify-content: center;
margin-top: 32px; margin-top: 32px;
} }
.cta__btns :global(.dl) {
align-items: center;
}
.cta__btns :global(.dl__os) {
justify-content: center;
}
.cta__offer { .cta__offer {
display: flex; display: flex;
justify-content: center; justify-content: center;
+17 -17
View File
@@ -1,10 +1,12 @@
--- ---
import { site, platforms } from "../data/site"; import { site, platforms } from "../data/site";
import { getRelease } from "../data/release";
interface Props { interface Props {
large?: boolean; large?: boolean;
} }
const { large = false } = Astro.props; const { large = false } = Astro.props;
const release = await getRelease();
--- ---
<div class="dl"> <div class="dl">
@@ -22,17 +24,16 @@ const { large = false } = Astro.props;
</a> </a>
<div class="dl__os"> <div class="dl__os">
<span class="dl__also">Also for</span>
{ {
platforms.map((p) => ( platforms.map((p) => (
<a <a
class="dl__chip" class="dl__chip"
href={site.releasesUrl} href={release.urls[p.id]}
data-download-os={p.id} data-download-os={p.id}
rel="noopener" rel="noopener"
download download
aria-label={`Download for ${p.label}`} aria-label={`Download for ${p.label}`}
title={p.label} title={`Download for ${p.label}`}
> >
<img class="dl__logo" src={`/logos/${p.logo}`} alt={p.label} /> <img class="dl__logo" src={`/logos/${p.logo}`} alt={p.label} />
</a> </a>
@@ -45,38 +46,37 @@ const { large = false } = Astro.props;
.dl { .dl {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; align-items: center;
align-items: flex-start; gap: 16px;
} }
.dl__os { .dl__os {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; justify-content: center;
flex-wrap: wrap; gap: 12px;
}
.dl__also {
font-size: 0.82rem;
color: var(--ink-faint);
font-weight: 500;
} }
.dl__chip { .dl__chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; flex: none;
height: 40px; width: 44px;
height: 44px;
background: #ffffff; background: #ffffff;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: var(--r-md); border-radius: var(--r-md);
transition: box-shadow 0.18s var(--ease), transform 0.18s var(--ease); box-shadow: var(--shadow-page);
transition:
box-shadow 0.18s var(--ease),
transform 0.18s var(--ease);
} }
.dl__chip:hover { .dl__chip:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-pop); box-shadow: var(--shadow-pop);
} }
.dl__logo { .dl__logo {
width: 20px; width: 22px;
height: 20px; height: 22px;
object-fit: contain; object-fit: contain;
} }
</style> </style>
+24 -26
View File
@@ -1,14 +1,13 @@
--- ---
import { site } from "../data/site"; import { site, platforms } from "../data/site";
import { getRelease } from "../data/release";
const { repo, releasesUrl } = site; const { repo, releasesUrl } = site;
const { urls, version } = await getRelease();
const matches = Object.fromEntries(platforms.map((p) => [p.id, p.match]));
--- ---
<script define:vars={{ repo, releasesUrl }}> <script define:vars={{ repo, releasesUrl, urls, version, matches }}>
const SUFFIXES = {
mac: [".dmg"],
windows: [".msi", ".exe"],
linux: [".AppImage", ".deb", ".rpm"],
};
const LABELS = { mac: "macOS", windows: "Windows", linux: "Linux" }; const LABELS = { mac: "macOS", windows: "Windows", linux: "Linux" };
function detectOS() { function detectOS() {
@@ -21,20 +20,31 @@ const { repo, releasesUrl } = site;
} }
function pickAsset(assets, os) { function pickAsset(assets, os) {
for (const sfx of SUFFIXES[os] || []) { for (const sfx of matches[os] || []) {
const hit = assets.find((a) => a.name.toLowerCase().endsWith(sfx.toLowerCase())); const hit = assets.find((a) => a.name.toLowerCase().endsWith(sfx.toLowerCase()));
if (hit) return hit.browser_download_url; if (hit) return hit.browser_download_url;
} }
return null; return null;
} }
function apply(map, ver) {
const os = detectOS(); const os = detectOS();
document.querySelectorAll("[data-download]").forEach((el) => { document.querySelectorAll("[data-download]").forEach((el) => {
el.dataset.detectedOs = os;
const label = el.querySelector("[data-dl-label]"); const label = el.querySelector("[data-dl-label]");
if (label) label.textContent = "Download for " + LABELS[os]; if (label) label.textContent = "Download for " + LABELS[os];
if (map[os]) el.href = map[os];
}); });
document.querySelectorAll("[data-download-os]").forEach((el) => {
const url = map[el.dataset.downloadOs];
if (url) el.href = url;
});
document.querySelectorAll(".dl__os [data-download-os]").forEach((el) => {
if (el.dataset.downloadOs === os) el.remove();
});
if (ver) document.querySelectorAll("[data-version]").forEach((el) => (el.textContent = ver));
}
apply(urls, version);
(async () => { (async () => {
try { try {
@@ -44,23 +54,11 @@ const { repo, releasesUrl } = site;
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const assets = data.assets || []; const assets = data.assets || [];
const version = data.tag_name || ""; const fresh = {};
for (const os of Object.keys(matches)) fresh[os] = pickAsset(assets, os) || urls[os] || releasesUrl;
document.querySelectorAll("[data-download]").forEach((el) => { apply(fresh, data.tag_name || version);
const url = pickAsset(assets, el.dataset.detectedOs || os);
if (url) el.href = url;
});
document.querySelectorAll("[data-download-os]").forEach((el) => {
const url = pickAsset(assets, el.dataset.downloadOs);
if (url) el.href = url;
});
if (version) {
document.querySelectorAll("[data-version]").forEach((el) => {
el.textContent = version;
});
}
} catch (e) { } catch (e) {
/* keep the releases-page fallback links */ /* keep the build-time links */
} }
})(); })();
</script> </script>
+37 -12
View File
@@ -1,29 +1,32 @@
--- ---
import { site } from "../data/site"; import { site } from "../data/site";
import { getRelease } from "../data/release";
import Logo from "./Logo.astro"; import Logo from "./Logo.astro";
const release = await getRelease();
const cols = [ const cols = [
{ {
title: "Get Margin", title: "Get Margin",
links: [ links: [
{ label: "Download for macOS", href: site.releasesUrl, os: "mac" }, { label: "Download for macOS", href: release.urls.mac, os: "mac" },
{ label: "Download for Windows", href: site.releasesUrl, os: "windows" }, { label: "Download for Windows", href: release.urls.windows, os: "windows" },
{ label: "Download for Linux", href: site.releasesUrl, os: "linux" }, { label: "Download for Linux", href: release.urls.linux, os: "linux" },
], ],
}, },
{ {
title: "Product", title: "Product",
links: [ links: [
{ label: "Features", href: "#features" }, { label: "Features", href: "/#features" },
{ label: "Publishing", href: "#publish" }, { label: "Publishing", href: "/#publish" },
{ label: "Compare", href: "#compare" }, { label: "Compare", href: "/#compare" },
{ label: "Pricing", href: "#pricing" }, { label: "Pricing", href: "/#pricing" },
], ],
}, },
{ {
title: "Help", title: "Help",
links: [ links: [
{ label: "Questions", href: "#faq" }, { label: "Questions", href: "/#faq" },
{ label: "Email us", href: `mailto:${site.supportEmail}` }, { label: "Email us", href: `mailto:${site.supportEmail}` },
], ],
}, },
@@ -47,14 +50,14 @@ const cols = [
<p class="footer__title">{col.title}</p> <p class="footer__title">{col.title}</p>
<ul> <ul>
{col.links.map((l) => { {col.links.map((l) => {
const isAnchor = l.href.startsWith("#") || l.href.startsWith("mailto:"); const isExternal = l.href.startsWith("http");
return ( return (
<li> <li>
<a <a
href={l.href} href={l.href}
data-download-os={(l as { os?: string }).os} data-download-os={(l as { os?: string }).os}
target={isAnchor ? undefined : "_blank"} target={isExternal ? "_blank" : undefined}
rel={isAnchor ? undefined : "noopener"} rel={isExternal ? "noopener" : undefined}
> >
{l.label} {l.label}
</a> </a>
@@ -70,6 +73,10 @@ const cols = [
<div class="container footer__base"> <div class="container footer__base">
<span>© {site.name}. Built with <span class="heart">♥</span> in NYC</span> <span>© {site.name}. Built with <span class="heart">♥</span> in NYC</span>
<nav class="footer__legal" aria-label="Legal">
<a href="/privacy">Privacy</a>
<a href="/terms">Terms</a>
</nav>
</div> </div>
</footer> </footer>
@@ -133,10 +140,23 @@ const cols = [
.footer__base { .footer__base {
padding-top: 22px; padding-top: 22px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
text-align: center; display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
font-size: 0.82rem; font-size: 0.82rem;
color: var(--ink-faint); color: var(--ink-faint);
} }
.footer__legal {
display: flex;
gap: 18px;
}
.footer__legal a {
color: var(--ink-faint);
}
.footer__legal a:hover {
color: var(--ink);
}
.heart { .heart {
color: var(--danger); color: var(--danger);
} }
@@ -150,5 +170,10 @@ const cols = [
.footer__cols { .footer__cols {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.footer__base {
flex-direction: column;
gap: 12px;
text-align: center;
}
} }
</style> </style>
+22 -30
View File
@@ -11,63 +11,55 @@ import OfferBadge from "./OfferBadge.astro";
<span class="book-accent">Own</span> every word. <span class="book-accent">Own</span> every word.
</h1> </h1>
<p class="lede hero__lede"> <p class="lede hero__lede">
Margin is a calm, offline writing studio for authors. Write your book, format it A calm, offline studio to write, format, and publish your book to every store and to
beautifully, and send it to every store and to print, all from your own machine. No print. No subscription, no account. Buy once, own it forever.
subscription, no account. Buy once, own it forever.
</p> </p>
<div class="hero__cta">
<DownloadButtons large />
</div>
<div class="hero__offer">
<OfferBadge />
</div>
</div> </div>
<div class="container hero__art"> <div class="container hero__art">
<AppWindow /> <AppWindow />
</div> </div>
<div class="container hero__get" id="download">
<DownloadButtons large />
<div class="hero__offer">
<OfferBadge />
</div>
</div>
</section> </section>
<style> <style>
.hero { .hero {
padding-top: clamp(48px, 7vw, 92px); padding-top: clamp(36px, 4.5vw, 60px);
padding-bottom: clamp(40px, 6vw, 72px); padding-bottom: clamp(48px, 6vw, 80px);
background: background:
radial-gradient(120% 90% at 50% -10%, var(--paper) 0%, transparent 60%), radial-gradient(120% 90% at 50% -10%, var(--paper) 0%, transparent 60%),
var(--shell); var(--shell);
} }
.hero__head { .hero__head {
text-align: center; text-align: center;
max-width: 760px; max-width: 720px;
} }
h1 { h1 {
font-size: clamp(2.6rem, 1.6rem + 4.4vw, 4.5rem); font-size: clamp(2.4rem, 1.6rem + 3.4vw, 3.9rem);
letter-spacing: -0.02em; letter-spacing: -0.02em;
margin-bottom: 22px; margin-bottom: 18px;
} }
.hero__lede { .hero__lede {
margin-inline: auto; margin-inline: auto;
max-width: 56ch; max-width: 50ch;
} }
.hero__cta { .hero__art {
display: flex; margin-top: clamp(32px, 4vw, 48px);
justify-content: center; max-width: 1000px;
margin-top: 34px;
} }
.hero__cta :global(.dl) { .hero__get {
align-items: center; margin-top: clamp(24px, 3vw, 32px);
} scroll-margin-top: 96px;
.hero__cta :global(.dl__os) {
justify-content: center;
} }
.hero__offer { .hero__offer {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-top: 30px; margin-top: 22px;
}
.hero__art {
margin-top: clamp(44px, 6vw, 72px);
max-width: 1000px;
} }
</style> </style>
+19 -5
View File
@@ -11,14 +11,20 @@ import Logo from "./Logo.astro";
</a> </a>
<nav class="nav__links" aria-label="Primary"> <nav class="nav__links" aria-label="Primary">
<a href="#features">Features</a> <a href="/#features">Features</a>
<a href="#publish">Publishing</a> <a href="/#publish">Publishing</a>
<a href="#compare">Compare</a> <a href="/#compare">Compare</a>
<a href="#pricing">Pricing</a> <a href="/#pricing">Pricing</a>
<a href="#faq">Questions</a> <a href="/#faq">Questions</a>
</nav> </nav>
<div class="nav__actions"> <div class="nav__actions">
<a class="btn nav__cta" href="/#download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
Download
</a>
<button class="iconlink" id="theme-toggle" aria-label="Toggle theme" type="button"> <button class="iconlink" id="theme-toggle" aria-label="Toggle theme" type="button">
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"> <svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle> <circle cx="12" cy="12" r="4"></circle>
@@ -89,6 +95,14 @@ import Logo from "./Logo.astro";
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.nav__cta {
padding: 0.5em 1.05em;
font-size: 0.9rem;
}
.nav__cta svg {
width: 1em;
height: 1em;
}
.iconlink { .iconlink {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
-1
View File
@@ -20,7 +20,6 @@ const { cap, left } = site.launch;
border-radius: 999px; border-radius: 999px;
background: color-mix(in srgb, var(--good) 12%, var(--paper)); background: color-mix(in srgb, var(--good) 12%, var(--paper));
border: 1px solid color-mix(in srgb, var(--good) 32%, transparent); border: 1px solid color-mix(in srgb, var(--good) 32%, transparent);
box-shadow: var(--shadow-page);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: var(--ink); color: var(--ink);
-6
View File
@@ -182,12 +182,6 @@ const included = [
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.price-card__cta :global(.dl) {
align-items: center;
}
.price-card__cta :global(.dl__os) {
justify-content: center;
}
.price-card__note { .price-card__note {
margin-top: 26px; margin-top: 26px;
font-size: 0.84rem; font-size: 0.84rem;
+52
View File
@@ -0,0 +1,52 @@
// Resolves the latest GitHub release to a direct download URL per OS, at build
// time. The picked file follows each platform's `match` order in site.ts.
// Falls back to the releases page if the release can't be read.
import { site, platforms } from "./site";
type Asset = { name: string; browser_download_url: string };
export type Release = {
version: string;
urls: Record<string, string>;
};
function pickAsset(assets: Asset[], match: string[]): string | null {
for (const sfx of match) {
const hit = assets.find((a) => a.name.toLowerCase().endsWith(sfx.toLowerCase()));
if (hit) return hit.browser_download_url;
}
return null;
}
function fallback(): Release {
return {
version: "",
urls: Object.fromEntries(platforms.map((p) => [p.id, site.releasesUrl])),
};
}
async function fetchRelease(): Promise<Release> {
try {
const res = await fetch(`https://api.github.com/repos/${site.repo}/releases/latest`, {
headers: { Accept: "application/vnd.github+json" },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) return fallback();
const data = await res.json();
const assets: Asset[] = data.assets || [];
const urls = Object.fromEntries(
platforms.map((p) => [p.id, pickAsset(assets, p.match) ?? site.releasesUrl]),
);
return { version: data.tag_name ?? "", urls };
} catch {
return fallback();
}
}
let cached: Promise<Release> | null = null;
export function getRelease(): Promise<Release> {
cached ??= fetchRelease();
return cached;
}
+4 -4
View File
@@ -5,7 +5,7 @@ export const site = {
tagline: "Write your book. Own every word.", tagline: "Write your book. Own every word.",
description: description:
"Margin is a calm, offline writing studio for authors. Write your book and send it to print and to every ebook store, all from your own machine. Buy once, own it forever.", "Margin is a calm, offline writing studio for authors. Write your book and send it to print and to every ebook store, all from your own machine. Buy once, own it forever.",
domain: "https://margin.studio", domain: "https://margin.73ai.org",
// The regular one-time price, shown struck through during the launch offer. // The regular one-time price, shown struck through during the launch offer.
price: "$10", price: "$10",
@@ -16,7 +16,7 @@ export const site = {
left: 12, left: 12,
}, },
supportEmail: "[email protected]", supportEmail: "[email protected]",
// Repo that hosts the release files. Used only to resolve the right download // Repo that hosts the release files. Used only to resolve the right download
// per OS at runtime. It is never shown to the reader. // per OS at runtime. It is never shown to the reader.
@@ -39,8 +39,8 @@ export type Platform = {
export const platforms: Platform[] = [ export const platforms: Platform[] = [
{ id: "mac", label: "macOS", logo: "macos.svg", match: [".dmg"] }, { id: "mac", label: "macOS", logo: "macos.svg", match: [".dmg"] },
{ id: "windows", label: "Windows", logo: "windows.svg", match: [".msi", ".exe"] }, { id: "windows", label: "Windows", logo: "windows.svg", match: [".exe", ".msi"] },
{ id: "linux", label: "Linux", logo: "linux.svg", match: [".AppImage", ".deb", ".rpm"] }, { id: "linux", label: "Linux", logo: "linux.svg", match: [".deb", ".AppImage", ".rpm"] },
]; ];
// Full-color store logos, served from /public/logos. Shown on white tiles so // Full-color store logos, served from /public/logos. Shown on white tiles so
+100
View File
@@ -0,0 +1,100 @@
---
import Base from "./Base.astro";
import Nav from "../components/Nav.astro";
import Footer from "../components/Footer.astro";
import { site } from "../data/site";
interface Props {
title: string;
updated: string;
description?: string;
}
const { title, updated, description } = Astro.props;
---
<Base title={`${title} · ${site.name}`} description={description}>
<Nav />
<main class="legal">
<div class="container legal__inner">
<header class="legal__head">
<p class="eyebrow">Legal</p>
<h1>{title}</h1>
<p class="legal__updated">Last updated {updated}</p>
</header>
<div class="legal__prose">
<slot />
</div>
</div>
</main>
<Footer />
</Base>
<style>
.legal {
padding-block: clamp(48px, 7vw, 96px);
}
.legal__inner {
max-width: 760px;
}
.legal__head {
padding-bottom: clamp(28px, 4vw, 44px);
margin-bottom: clamp(28px, 4vw, 44px);
border-bottom: 1px solid var(--line);
}
.legal__head h1 {
font-family: var(--font-book);
font-weight: 560;
letter-spacing: -0.012em;
line-height: 1.08;
font-size: clamp(2.1rem, 1.4rem + 2.8vw, 3.2rem);
}
.legal__updated {
margin-top: 16px;
color: var(--ink-faint);
font-size: 0.92rem;
}
.legal__prose {
color: var(--ink-soft);
font-size: 1.04rem;
line-height: 1.7;
}
.legal__prose :global(h2) {
font-size: clamp(1.3rem, 1.05rem + 0.9vw, 1.6rem);
color: var(--ink);
margin: 44px 0 14px;
}
.legal__prose :global(h2:first-child) {
margin-top: 0;
}
.legal__prose :global(p) {
margin: 0 0 16px;
}
.legal__prose :global(ul) {
margin: 0 0 16px;
padding-left: 22px;
display: flex;
flex-direction: column;
gap: 8px;
}
.legal__prose :global(li) {
padding-left: 4px;
}
.legal__prose :global(li::marker) {
color: var(--ink-faint);
}
.legal__prose :global(strong) {
color: var(--ink);
font-weight: 600;
}
.legal__prose :global(a) {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: var(--line-strong);
}
.legal__prose :global(a:hover) {
text-decoration-color: var(--ink);
}
</style>
+76
View File
@@ -0,0 +1,76 @@
---
import Legal from "../layouts/Legal.astro";
import { site } from "../data/site";
---
<Legal
title="Privacy Policy"
updated="June 22, 2026"
description={`How ${site.name} handles your data. The short version: your book never leaves your machine, and we keep no copy.`}
>
<h2>The short version</h2>
<p>
Margin is an offline writing studio that runs on your own computer. Your book and everything
you write stay on your device. We never receive a copy, and nothing you write is sent away to
be read or stored by us. This page explains the few places where data is involved, so you know
exactly what happens and what does not.
</p>
<h2>What stays on your device</h2>
<p>
Your manuscript, your spelling and grammar checks, your fonts and book setup, and every print
and ebook export all happen locally on your machine. None of this requires an account, a login,
or an internet connection, and none of it passes through our servers.
</p>
<h2>No account, no tracking</h2>
<p>
You do not need to sign up, log in, or give us an email address to write with Margin. This
website does not use tracking cookies, advertising pixels, or third-party analytics. The only
thing the site stores in your browser is your light or dark theme preference, kept locally so
the page looks the way you left it.
</p>
<h2>Downloads and updates</h2>
<p>
Margin is distributed through GitHub Releases. When you download the app, or when the app checks
for a new version to keep itself up to date, a request is made to GitHub to fetch the latest
release. As with any web request, GitHub can see your IP address for that request, handled under
GitHub's own privacy policy. We do not add any tracking of our own to these requests.
</p>
<h2>Optional backup to your Google Drive</h2>
<p>
Margin can save a backup of your book to your own Google Drive, but only if you turn it on. When
you do, you connect your own Google account, and the backup is written to your Drive, in your
account, under your control. We never receive a copy of that backup and cannot read it. You can
disconnect Margin from your Google account at any time from your Google account settings, and the
app works exactly the same with the feature switched off.
</p>
<h2>Payments</h2>
<p>
If you buy Margin, the payment is handled by a third-party payment processor. We do not see or
store your full card details. The processor handles the transaction and shares with us only what
we need to confirm your purchase and provide support.
</p>
<h2>Children</h2>
<p>
Margin is a tool for writers and is not directed at children under 13. We do not knowingly
collect personal information from children.
</p>
<h2>Changes to this policy</h2>
<p>
If we update this policy, we will change the date at the top of this page. Because the app keeps
your book on your own device, the spirit of this policy is unlikely to change: your words belong
to you.
</p>
<h2>Contact</h2>
<p>
Questions about your privacy? Write to <a href={`mailto:${site.supportEmail}`}>{site.supportEmail}</a>
and a real person will answer.
</p>
</Legal>
+92
View File
@@ -0,0 +1,92 @@
---
import Legal from "../layouts/Legal.astro";
import { site } from "../data/site";
---
<Legal
title="Terms of Use"
updated="June 22, 2026"
description={`The terms for using ${site.name}: a one-time purchase, your content stays yours, used as is.`}
>
<h2>Acceptance</h2>
<p>
These terms cover your use of Margin, the offline writing app, and this website. By downloading,
buying, or using Margin, you agree to them. If you do not agree, please do not use the app.
</p>
<h2>Your license to use Margin</h2>
<p>
Margin is a one-time purchase. When you buy it, you get a personal, non-exclusive license to
install and use the app on the devices you own. You keep using the version you bought, and
updates are included at no extra cost. You may not resell, rent, or redistribute the app, or
attempt to defeat its purchase, and you may not reverse engineer it except where the law
expressly allows.
</p>
<h2>Your content is yours</h2>
<p>
Everything you write in Margin belongs to you. Your book is a file on your own computer. We claim
no ownership of it, take no license to it, and never receive a copy. You are responsible for your
own work, including keeping your own backups and making sure you have the right to publish what
you write.
</p>
<h2>Acceptable use</h2>
<p>
Use Margin lawfully. Do not use it to create or distribute material that is illegal, or that
infringes someone else's rights. What you choose to write and publish is your responsibility, not
ours.
</p>
<h2>Third-party services</h2>
<p>
Margin can connect to services you choose, such as your own Google Drive for backups, and you
download and publish through platforms like GitHub and the ebook and print stores. Your use of
those services is governed by their own terms and policies, which we do not control.
</p>
<h2>Updates and availability</h2>
<p>
We work to keep Margin reliable and to improve it over time, but we may change, pause, or
discontinue parts of the app or this website. Because Margin runs on your own machine, the copy
you have installed keeps working even if a feature that depends on an outside service changes.
</p>
<h2>The app is provided as is</h2>
<p>
Margin is provided "as is" and "as available," without warranties of any kind, whether express or
implied, including fitness for a particular purpose and non-infringement. We do not warrant that
the app will be error-free or uninterrupted, or that it will meet every need. You are responsible
for backing up your own work.
</p>
<h2>Limitation of liability</h2>
<p>
To the fullest extent permitted by law, Margin and its maker will not be liable for any indirect,
incidental, or consequential damages, or for any loss of data or content, arising from your use
of the app. Our total liability for any claim is limited to the amount you paid for Margin.
</p>
<h2>Refunds</h2>
<p>
If Margin is not working for you, write to us. We would rather make it right or refund your
purchase than have you stuck with a tool you cannot use.
</p>
<h2>Changes to these terms</h2>
<p>
We may update these terms from time to time. When we do, we will change the date at the top of
this page. Continuing to use Margin after a change means you accept the updated terms.
</p>
<h2>Governing law</h2>
<p>
These terms are governed by the laws of the State of New York, United States, without regard to
its conflict of laws rules.
</p>
<h2>Contact</h2>
<p>
Questions about these terms? Write to <a href={`mailto:${site.supportEmail}`}>{site.supportEmail}</a>.
</p>
</Legal>