9 Commits
Author SHA1 Message Date
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
47 changed files with 2623 additions and 122 deletions

No files matched your search

+4
View File
@@ -26,6 +26,10 @@ dist-ssr
# Tauri build output
src-tauri/target/
# Google OAuth desktop client (real values, never commit)
/google-credentials.json
/client_secret_*.json
# Screenshots & Playwright MCP artifacts
.playwright-mcp/
/*.png
+1 -1
View File
@@ -10,7 +10,7 @@
## Git Commit Rules
- Do not make branches, commit in main only
- 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.
- 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
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).
## 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).
Write your book. Own every word. A calm, offline studio to write, format, and publish your book to every store and to print.
+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",
"private": true,
"version": "0.1.4",
"version": "0.1.5",
"type": "module",
"scripts": {
"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"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -3754,6 +3756,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -4632,6 +4635,12 @@ dependencies = [
"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]]
name = "lzma-rs"
version = "0.3.0"
@@ -4704,12 +4713,16 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "margin-app"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"base64 0.22.1",
"fontdb",
"harper-core",
"rand 0.8.6",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"spellbook",
"tauri",
"tauri-build",
@@ -4720,6 +4733,7 @@ dependencies = [
"typst",
"typst-as-lib",
"typst-pdf",
"url",
"zip 2.4.2",
]
@@ -5966,6 +5980,61 @@ dependencies = [
"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]]
name = "quote"
version = "1.0.45"
@@ -5993,6 +6062,8 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
"serde",
]
@@ -6033,6 +6104,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
"serde",
]
@@ -6203,6 +6275,44 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "reqwest"
version = "0.13.4"
@@ -6425,6 +6535,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@@ -6773,6 +6884,18 @@ dependencies = [
"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]]
name = "serde_with"
version = "3.21.0"
@@ -7421,7 +7544,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -7610,7 +7733,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
@@ -9149,6 +9272,15 @@ dependencies = [
"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]]
name = "webview2-com"
version = "0.38.2"
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "margin-app"
version = "0.1.4"
version = "0.1.5"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -27,6 +27,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
zip = "2"
fontdb = "0.23"
typst = "0.14.2"
typst-pdf = "0.14.2"
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,
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
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).
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
-11
View File
@@ -5,9 +5,6 @@ use std::io::{Read, Write};
use zip::write::SimpleFileOptions;
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)]
pub struct EpubFile {
path: String,
@@ -48,14 +45,6 @@ fn build(files: &[EpubFile]) -> Result<Vec<u8>, 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())
}
+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 fonts;
mod gdrive;
mod library;
mod pdf;
mod project;
@@ -71,6 +73,11 @@ pub fn run() {
builder
.manage(proofing::new_state())
.manage(gdrive::GDriveState::default())
.setup(|app| {
gdrive::init_session(app.handle());
Ok(())
})
.menu(|handle| build_menu(handle))
.on_menu_event(|app, event| {
if matches!(
@@ -83,6 +90,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
epub::package_epub,
epub::unzip_epub,
fonts::list_system_fonts,
pdf::compile_pdf,
project::read_file,
project::write_file,
@@ -92,7 +100,13 @@ pub fn run() {
library::save_book,
library::delete_book,
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)
.expect("error while running margin");
+8 -6
View File
@@ -19,12 +19,14 @@ fn corrupt_summary(stem: &str) -> BookSummary {
}
}
fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("library");
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())?;
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)
}
+48 -3
View File
@@ -10,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 HANKEN: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-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)]
pub struct ImageInput {
@@ -17,7 +40,12 @@ pub struct ImageInput {
data: String,
}
fn compile(source: String, images: &[ImageInput]) -> Result<(Vec<u8>, String), 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());
for image in images {
let bytes = STANDARD
@@ -26,9 +54,24 @@ fn compile(source: String, images: &[ImageInput]) -> Result<(Vec<u8>, String), S
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()
.main_file(source)
.fonts([LITERATA, LITERATA_ITALIC, HANKEN, HANKEN_ITALIC])
.fonts(fonts)
.with_static_file_resolver(binaries)
.build();
@@ -50,8 +93,10 @@ pub fn compile_pdf(
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)?;
let (bytes, warnings) = compile(source, &images, &bundled_fonts, &system_fonts)?;
if emit_warnings && !warnings.is_empty() {
app.emit("pdf-warnings", warnings).ok();
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "margin",
"version": "0.1.4",
"version": "0.1.5",
"identifier": "studio.margin.app",
"build": {
"beforeDevCommand": "pnpm dev",
+39 -3
View File
@@ -3,7 +3,9 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Library } from "./components/Library";
import { EditorView } from "./components/EditorView";
import { BackupSettings } from "./components/BackupSettings";
import { useBook } from "./store/useBook";
import { useBackup } from "./store/useBackup";
import { isDesktop } from "./ipc";
import { createAndOpenBook, saveBook } from "./library";
import { runExport } from "./export/run";
@@ -37,9 +39,16 @@ function App() {
const win = getCurrentWindow();
const unlisten = win.onCloseRequested(async (event) => {
const { book, dirty } = useBook.getState();
if (!book || !dirty) return;
const { connected } = useBackup.getState();
if ((!book || !dirty) && !connected) return;
event.preventDefault();
await saveBook(book).catch(() => {});
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 () => {
@@ -47,7 +56,34 @@ function App() {
};
}, []);
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;
+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)}
/>
)}
</>
);
}
+2 -2
View File
@@ -107,8 +107,8 @@ function PdfDock() {
clearTimeout(timer.current);
setBusy(true);
timer.current = setTimeout(() => {
const { source, images } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
compilePdf(source, images)
const { source, images, fonts } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
compilePdf(source, images, false, fonts)
.then((bytes) => {
setPdf(bytes);
setError(null);
+4
View File
@@ -5,6 +5,7 @@ import { Dock } from "./Dock";
import { ResizeHandle } from "./ResizeHandle";
import { Icon } from "./Icon";
import { Settings } from "./Settings";
import { BackupButton } from "./BackupButton";
import { CoverView } from "./CoverView";
import { FindBar } from "./FindBar";
import { ProofPopover } from "./ProofPopover";
@@ -12,6 +13,7 @@ import { Editor } from "../editor/Editor";
import { FloatingToolbar } from "../editor/FloatingToolbar";
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
import { COVER_ID, useBook } from "../store/useBook";
import { useBackup } from "../store/useBackup";
import { useProofing } from "../store/useProofing";
import { useTheme } from "../store/useTheme";
import { useWidth } from "../store/useWidth";
@@ -79,6 +81,7 @@ export function EditorView() {
saveBook(current)
.then(() => {
if (useBook.getState().book === current) markSaved();
useBackup.getState().refresh();
})
.catch((e) => setNotice(`Save failed: ${e}`));
}, [markSaved, setNotice]);
@@ -163,6 +166,7 @@ export function EditorView() {
{dirty && <span className="dirty-dot" />}
</button>
<div className="actions">
{isDesktop && <BackupButton />}
<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" />
</button>
+27 -2
View File
@@ -7,13 +7,20 @@ import { isDesktop } from "../ipc";
import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon";
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 }) {
const [books, setBooks] = useState<BookSummary[]>([]);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | 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 = () =>
listBooks()
@@ -23,6 +30,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
useEffect(() => {
refresh();
}, []);
useEffect(() => {
if (restoreNonce) refresh();
}, [restoreNonce]);
useEffect(() => {
if (!notice) return;
const timer = setTimeout(() => setNotice(null), 4000);
return () => clearTimeout(timer);
}, [notice]);
const handleExample = async () => {
const copy = exampleBook();
@@ -64,7 +79,9 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
return (
<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">
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
<Icon d="M12 5v14M5 12h14" size={20} />
@@ -100,6 +117,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
),
)}
</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 && (
<div className="toast" onClick={() => setNotice(null)}>
{notice}
+102 -3
View File
@@ -1,6 +1,18 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
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";
const LANGUAGES = [
@@ -12,6 +24,8 @@ const LANGUAGES = [
{ value: "pt", label: "Portuguese" },
];
let systemFontCache: string[] | null = null;
interface Draft {
title: string;
subtitle: string;
@@ -19,6 +33,7 @@ interface Draft {
isbn: string;
language: string;
bleed: boolean;
fonts: BookFonts;
}
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 }) {
const book = useBook((s) => s.book);
const setMetadata = useBook((s) => s.setMetadata);
@@ -42,16 +81,31 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
isbn: book?.metadata.isbn ?? "",
language: book?.metadata.language ?? "en",
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;
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 { bleed, ...metadata } = draft;
const { bleed, fonts, ...metadata } = draft;
setMetadata(metadata);
setSettings({ bleed });
setSettings({ bleed, fonts });
onSave();
onClose();
};
@@ -87,6 +141,51 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
))}
</select>
</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">
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
Add bleed for full-page images (print)
+63 -24
View File
@@ -1,6 +1,7 @@
import type { JSONContent } from "@tiptap/core";
import { invoke } from "@tauri-apps/api/core";
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
import { fontStack, fontsUsed } from "../model/fonts";
export interface EpubFile {
path: string;
@@ -38,6 +39,56 @@ function mediaType(path: string): string {
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 {
if (node.type === "text") {
let text = esc(node.text ?? "");
@@ -240,6 +291,7 @@ function contentOpf(
chapterIds: string[],
assetPaths: string[],
cover: { href: string; mediaType: string },
fontManifest: string[],
): string {
const meta = book.metadata;
const manifest: string[] = [
@@ -247,8 +299,7 @@ function contentOpf(
`<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="css" href="style.css" media-type="text/css"/>`,
`<item id="font-regular" href="fonts/Literata-VF.ttf" media-type="font/ttf"/>`,
`<item id="font-italic" href="fonts/Literata-Italic-VF.ttf" media-type="font/ttf"/>`,
...fontManifest,
];
chapterIds.forEach((_, i) => {
manifest.push(
@@ -334,23 +385,9 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
</html>`;
}
function styleCss(): string {
return `@font-face {
font-family: "Literata";
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;
function styleCss(book: Book, faces: string): string {
return `${faces ? faces + "\n\n" : ""}html {
font-family: ${fontStack(book.settings.fonts.body)};
}
.cover {
@@ -383,6 +420,7 @@ p[data-indent="true"] {
}
h1, h2, h3 {
font-family: ${fontStack(book.settings.fonts.heading)};
hyphens: none;
line-height: 1.25;
font-weight: 500;
@@ -477,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 cover = buildCover(book);
const chapterIds = book.chapters.map((c) => c.id);
@@ -488,12 +526,12 @@ export function bookToEpub(book: Book): EpubFile[] {
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
{
path: "OEBPS/content.opf",
data: contentOpf(book, chapterIds, assetPaths, cover),
data: contentOpf(book, chapterIds, assetPaths, cover, fontAssets.manifest),
encoding: "utf8",
},
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), 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,
];
@@ -505,12 +543,13 @@ export function bookToEpub(book: Book): EpubFile[] {
});
});
files.push(...assets);
files.push(...assets, ...fontAssets.files);
return files;
}
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 });
return new Uint8Array(buf);
}
+2 -2
View File
@@ -8,8 +8,8 @@ const PDF = [{ name: "PDF", extensions: ["pdf"] }];
const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
export async function exportPdf(book: Book): Promise<void> {
const { source, images } = bookToPdfInputs(book);
const bytes = await compilePdf(source, images, true);
const { source, images, fonts } = bookToPdfInputs(book);
const bytes = await compilePdf(source, images, true, fonts);
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
}
+28 -13
View File
@@ -1,7 +1,18 @@
import type { JSONContent } from "@tiptap/core";
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";
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 }> = {
"6x9": { w: "6in", h: "9in" },
"5.5x8.5": { w: "5.5in", h: "8.5in" },
@@ -104,6 +115,8 @@ function preamble(book: Book): string {
const trim = TRIM[book.settings.trim];
const meta = book.metadata;
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)})
#set page(
width: ${trim.w} + ${bleed},
@@ -111,9 +124,9 @@ function preamble(book: Book): string {
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
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)
#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: 2): set block(above: 1.4em, below: 0.6em)
#show heading.where(level: 3): set block(above: 1.2em, below: 0.5em)
@@ -125,10 +138,10 @@ function preamble(book: Book): string {
#let titlepage(title, subtitle, author) = {
v(2.4in)
align(center)[
#text(font: "Literata", size: 30pt, weight: "medium")[#title]
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[#title]
#if subtitle != "" {
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 != "" {
v(1.7em)
@@ -164,7 +177,7 @@ function preamble(book: Book): string {
set par(justify: false, first-line-indent: 0pt)
block(below: 2em)[
#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 {
let items = query(<chap>).filter(it => it.value.toc != false)
@@ -200,13 +213,15 @@ function imageExtension(dataUrl: string): string {
function coverBlock(book: Book, coverPath?: string): string {
const c = book.cover;
const meta = book.metadata;
const body = fontFamilyName(book.settings.fonts.body);
const heading = fontFamilyName(book.settings.fonts.heading);
if (c.kind === "image" && coverPath) {
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
}
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
? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
@@ -216,7 +231,7 @@ function coverBlock(book: Book, coverPath?: string): string {
#set align(center)
#set text(fill: rgb(${str(c.ink)}))
#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)
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
${subtitle}
@@ -334,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`;
}
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 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 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 {
@@ -358,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 { images, paths } = collectImages([chapter.content]);
return { source: chapterToTypst(book, index, paths), images };
return { source: chapterToTypst(book, index, paths), images, fonts: pdfFonts(book) };
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import type { JSONContent } from "@tiptap/core";
import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book";
import { DEFAULT_FONTS } from "../model/fonts";
export interface RawFile {
path: string;
@@ -515,7 +516,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
id: crypto.randomUUID(),
metadata,
theme: "quiet-press",
settings: { trim: "6x9", bleed: true },
settings: { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS },
cover: findCover(opf, manifest, byPath),
chapters,
};
+22 -1
View File
@@ -7,11 +7,32 @@ export interface ImageInput {
data: string;
}
export interface PdfFonts {
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 });
const buffer = await invoke<ArrayBuffer>("compile_pdf", {
source,
images,
emitWarnings,
bundledFonts: fonts.bundled,
systemFonts: fonts.system,
});
return new Uint8Array(buffer);
}
export async function listSystemFonts(): Promise<string[]> {
if (!isDesktop) return [];
try {
return await invoke<string[]>("list_system_fonts");
} catch {
return [];
}
}
+4 -2
View File
@@ -1,4 +1,5 @@
import type { JSONContent } from "@tiptap/core";
import { type BookFonts, DEFAULT_FONTS } from "./fonts";
export type TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5";
export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top";
@@ -24,6 +25,7 @@ export interface BookMetadata {
export interface BookSettings {
trim: TrimSize;
bleed: boolean;
fonts: BookFonts;
}
export type CoverKind = "default" | "image";
@@ -149,14 +151,14 @@ export function createCover(): Cover {
}
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true };
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS };
export function normalizeBook(book: Book): Book {
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
return {
...book,
metadata: { ...DEFAULT_METADATA, ...book.metadata },
settings: { ...DEFAULT_SETTINGS, ...book.settings },
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
c.updatedAt ? c : { ...c, updatedAt: Date.now() },
+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 }),
}));
+9 -1
View File
@@ -14,6 +14,7 @@ import {
} from "../model/book";
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
import { saveBook } from "../library";
import { applyBookFonts, resetBookFonts } from "../book-style";
export const COVER_ID = "__cover__";
@@ -60,10 +61,12 @@ export const useBook = create<BookState>((set, get) => ({
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 });
},
setActiveChapter: (id) => set({ activeChapterId: id }),
@@ -140,7 +143,12 @@ export const useBook = create<BookState>((set, get) => ({
setMetadata: (patch) =>
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...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) =>
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
markSaved: () => set({ dirty: false }),
+266 -5
View File
@@ -427,6 +427,7 @@ body.resizing {
}
.chapter-title {
font-family: var(--font-heading);
font-weight: 500;
font-size: 31px;
line-height: 1.16;
@@ -434,6 +435,10 @@ body.resizing {
margin: 15px 0 0;
}
.prose :is(h1, h2, h3) {
font-family: var(--font-heading);
}
.prose p {
font-size: 19px;
line-height: 1.68;
@@ -565,11 +570,15 @@ body.resizing {
}
.page .p-title {
font-family: var(--font-book);
font-family: var(--font-heading);
font-size: 12px;
margin-top: 5px;
}
.page :is(h2, h3) {
font-family: var(--font-heading);
}
.page p {
font-family: var(--font-book);
font-size: 6.6px;
@@ -620,7 +629,7 @@ body.resizing {
border: 0;
background: transparent;
text-align: center;
font-family: var(--font-book);
font-family: var(--font-heading);
font-weight: 500;
font-size: 31px;
line-height: 1.16;
@@ -660,6 +669,87 @@ body.resizing {
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,
.ProseMirror {
outline: none;
@@ -1167,6 +1257,10 @@ body.resizing {
.library-head {
height: 44px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 14px;
}
.shelf {
@@ -1432,7 +1526,7 @@ body.resizing {
}
.cover-title {
font-family: var(--font-book);
font-family: var(--font-heading);
font-weight: 500;
font-size: 9.5cqw;
line-height: 1.08;
@@ -2108,7 +2202,7 @@ body.resizing {
}
.device-title {
font-family: var(--font-book);
font-family: var(--font-heading);
font-weight: 500;
font-size: calc(24 * var(--dv-scale));
line-height: 1.2;
@@ -2138,7 +2232,7 @@ body.resizing {
.device-body h1,
.device-body h2,
.device-body h3 {
font-family: var(--font-book);
font-family: var(--font-heading);
font-weight: 600;
line-height: 1.25;
margin: 1.2em 0 0.5em;
@@ -2193,3 +2287,170 @@ body.resizing {
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-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 {
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-book: "Literata", Georgia, "Times New Roman", serif;
--font-heading: "Literata", Georgia, "Times New Roman", serif;
--r-sm: 5px;
--r-md: 8px;