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

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

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

* fix(editor): isolate undo history per chapter

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

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

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

* feat(editor): persist active chapter per book

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

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

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

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

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

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

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

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

* fix(persist): persist new books on create

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

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

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

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

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

Validated the generated Typst constructs compile with typst 0.15.

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

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

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

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

* fix(ui): handle library-open errors

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

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

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

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

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

* fix(library): surface unreadable books

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

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

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

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

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

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

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

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

* fix(security): validate book id paths

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

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

* chore(security): restrictive content security policy

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

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

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

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

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

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

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

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

* fix(import): keep table rows together

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

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

* fix(import): import the cover image

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

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

* fix(import): validate ISBN with checksum

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

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

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

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

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

* changes
2026-06-22 13:44:30 -04:00
pj 61bd7cf6e5 changes 2026-06-22 13:03:11 -04:00
116 changed files with 56228 additions and 431 deletions

No files matched your search

+13
View File
@@ -137,6 +137,19 @@ jobs:
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Provision Google credentials
shell: bash
env:
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
run: |
if [ -n "$GOOGLE_CREDENTIALS" ]; then
printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json
echo "Wrote google-credentials.json from GOOGLE_CREDENTIALS secret."
else
cp google-credentials.example.json google-credentials.json
echo "::warning::GOOGLE_CREDENTIALS secret not set — embedding placeholder credentials; Google Drive backup will be disabled in this release."
fi
- name: Build and upload
uses: tauri-apps/tauri-action@v0
env:
+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
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<link rel="icon" type="image/png" href="/margin-mark.png" />
<script>
(function () {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "margin-app",
"private": true,
"version": "0.1.3",
"version": "0.1.8",
"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.2"
version = "0.1.7"
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.3"
version = "0.1.8"
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
+2 -3
View File
@@ -5,9 +5,8 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-destroy",
"opener:default",
"dialog:default",
"updater:default",
"process:allow-restart"
"dialog:default"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop",
"description": "Desktop-only permissions (updater, process restart)",
"platforms": ["macOS", "windows", "linux"],
"windows": ["main"],
"permissions": ["updater:default", "process:allow-restart"]
}
+3
View File
@@ -0,0 +1,3 @@
xcuserdata/
build/
Externals/
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,116 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "[email protected]",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>debugging</string>
</dict>
</plist>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+21
View File
@@ -0,0 +1,21 @@
# Uncomment the next line to define a global platform for your project
target 'margin-app_iOS' do
platform :ios, '14.0'
# Pods for margin-app_iOS
end
target 'margin-app_macOS' do
platform :osx, '11.0'
# Pods for margin-app_macOS
end
# Delete the deployment target for iOS and macOS, causing it to be inherited from the Podfile
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
config.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET'
end
end
end
@@ -0,0 +1,8 @@
#pragma once
namespace ffi {
extern "C" {
void start_app();
}
}
@@ -0,0 +1,6 @@
#include "bindings/bindings.h"
int main(int argc, char * argv[]) {
ffi::start_app();
return 0;
}
@@ -0,0 +1,347 @@
en_US Hunspell Dictionary
Version 2020.12.07
Mon Dec 7 20:14:35 2020 -0500 [5ef55f9]
http://wordlist.sourceforge.net
README file for English Hunspell dictionaries derived from SCOWL.
These dictionaries are created using the speller/make-hunspell-dict
script in SCOWL.
The following dictionaries are available:
en_US (American)
en_CA (Canadian)
en_GB-ise (British with "ise" spelling)
en_GB-ize (British with "ize" spelling)
en_AU (Australian)
en_US-large
en_CA-large
en_GB-large (with both "ise" and "ize" spelling)
en_AU-large
The normal (non-large) dictionaries correspond to SCOWL size 60 and,
to encourage consistent spelling, generally only include one spelling
variant for a word. The large dictionaries correspond to SCOWL size
70 and may include multiple spelling for a word when both variants are
considered almost equal. The larger dictionaries however (1) have not
been as carefully checked for errors as the normal dictionaries and
thus may contain misspelled or invalid words; and (2) contain
uncommon, yet valid, words that might cause problems as they are
likely to be misspellings of more common words (for example, "ort" and
"calender").
To get an idea of the difference in size, here are 25 random words
only found in the large dictionary for American English:
Bermejo Freyr's Guenevere Hatshepsut Nottinghamshire arrestment
crassitudes crural dogwatches errorless fetial flaxseeds godroon
incretion jalapeño's kelpie kishkes neuroglias pietisms pullulation
stemwinder stenoses syce thalassic zees
The en_US, en_CA and en_AU are the official dictionaries for Hunspell.
The en_GB and large dictionaries are made available on an experimental
basis. If you find them useful please send me a quick email at
[email protected].
If none of these dictionaries suite you (for example, maybe you want
the normal dictionary that also includes common variants) additional
dictionaries can be generated at http://app.aspell.net/create or by
modifying speller/make-hunspell-dict in SCOWL. Please do let me know
if you end up publishing a customized dictionary.
If a word is not found in the dictionary or a word is there you think
shouldn't be, you can lookup the word up at http://app.aspell.net/lookup
to help determine why that is.
General comments on these list can be sent directly to me at
[email protected] or to the wordlist-devel mailing lists
(https://lists.sourceforge.net/lists/listinfo/wordlist-devel). If you
have specific issues with any of these dictionaries please file a bug
report at https://github.com/kevina/wordlist/issues.
IMPORTANT CHANGES INTRODUCED In 2016.11.20:
New Australian dictionaries thanks to the work of Benjamin Titze
([email protected]).
IMPORTANT CHANGES INTRODUCED IN 2016.04.24:
The dictionaries are now in UTF-8 format instead of ISO-8859-1. This
was required to handle smart quotes correctly.
IMPORTANT CHANGES INTRODUCED IN 2016.01.19:
"SET UTF8" was changes to "SET UTF-8" in the affix file as some
versions of Hunspell do not recognize "UTF8".
ADDITIONAL NOTES:
The NOSUGGEST flag was added to certain taboo words. While I made an
honest attempt to flag the strongest taboo words with the NOSUGGEST
flag, I MAKE NO GUARANTEE THAT I FLAGGED EVERY POSSIBLE TABOO WORD.
The list was originally derived from Németh László, however I removed
some words which, while being considered taboo by some dictionaries,
are not really considered swear words in today's society.
COPYRIGHT, SOURCES, and CREDITS:
The English dictionaries come directly from SCOWL
and is thus under the same copyright of SCOWL. The affix file is
a heavily modified version of the original english.aff file which was
released as part of Geoff Kuenning's Ispell and as such is covered by
his BSD license. Part of SCOWL is also based on Ispell thus the
Ispell copyright is included with the SCOWL copyright.
The collective work is Copyright 2000-2018 by Kevin Atkinson as well
as any of the copyrights mentioned below:
Copyright 2000-2018 by Kevin Atkinson
Permission to use, copy, modify, distribute and sell these word
lists, the associated scripts, the output created from the scripts,
and its documentation for any purpose is hereby granted without fee,
provided that the above copyright notice appears in all copies and
that both that copyright notice and this permission notice appear in
supporting documentation. Kevin Atkinson makes no representations
about the suitability of this array for any purpose. It is provided
"as is" without express or implied warranty.
Alan Beale <[email protected]> also deserves special credit as he has,
in addition to providing the 12Dicts package and being a major
contributor to the ENABLE word list, given me an incredible amount of
feedback and created a number of special lists (those found in the
Supplement) in order to help improve the overall quality of SCOWL.
The 10 level includes the 1000 most common English words (according to
the Moby (TM) Words II [MWords] package), a subset of the 1000 most
common words on the Internet (again, according to Moby Words II), and
frequently class 16 from Brian Kelk's "UK English Wordlist
with Frequency Classification".
The MWords package was explicitly placed in the public domain:
The Moby lexicon project is complete and has
been place into the public domain. Use, sell,
rework, excerpt and use in any way on any platform.
Placing this material on internal or public servers is
also encouraged. The compiler is not aware of any
export restrictions so freely distribute world-wide.
You can verify the public domain status by contacting
Grady Ward
3449 Martha Ct.
Arcata, CA 95521-4884
[email protected]
[email protected]
The "UK English Wordlist With Frequency Classification" is also in the
Public Domain:
Date: Sat, 08 Jul 2000 20:27:21 +0100
From: Brian Kelk <[email protected]>
> I was wondering what the copyright status of your "UK English
> Wordlist With Frequency Classification" word list as it seems to
> be lacking any copyright notice.
There were many many sources in total, but any text marked
"copyright" was avoided. Locally-written documentation was one
source. An earlier version of the list resided in a filespace called
PUBLIC on the University mainframe, because it was considered public
domain.
Date: Tue, 11 Jul 2000 19:31:34 +0100
> So are you saying your word list is also in the public domain?
That is the intention.
The 20 level includes frequency classes 7-15 from Brian's word list.
The 35 level includes frequency classes 2-6 and words appearing in at
least 11 of 12 dictionaries as indicated in the 12Dicts package. All
words from the 12Dicts package have had likely inflections added via
my inflection database.
The 12Dicts package and Supplement is in the Public Domain.
The WordNet database, which was used in the creation of the
Inflections database, is under the following copyright:
This software and database is being provided to you, the LICENSEE,
by Princeton University under the following license. By obtaining,
using and/or copying this software and database, you agree that you
have read, understood, and will comply with these terms and
conditions.:
Permission to use, copy, modify and distribute this software and
database and its documentation for any purpose and without fee or
royalty is hereby granted, provided that you agree to comply with
the following copyright notice and statements, including the
disclaimer, and that the same appear on ALL copies of the software,
database and documentation, including modifications that you make
for internal use or for distribution.
WordNet 1.6 Copyright 1997 by Princeton University. All rights
reserved.
THIS SOFTWARE AND DATABASE IS PROVIDED "AS IS" AND PRINCETON
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PRINCETON
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES OF MERCHANT-
ABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE
LICENSED SOFTWARE, DATABASE OR DOCUMENTATION WILL NOT INFRINGE ANY
THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
The name of Princeton University or Princeton may not be used in
advertising or publicity pertaining to distribution of the software
and/or database. Title to copyright in this software, database and
any associated documentation shall at all times remain with
Princeton University and LICENSEE agrees to preserve same.
The 40 level includes words from Alan's 3esl list found in version 4.0
of his 12dicts package. Like his other stuff the 3esl list is also in the
public domain.
The 50 level includes Brian's frequency class 1, words appearing
in at least 5 of 12 of the dictionaries as indicated in the 12Dicts
package, and uppercase words in at least 4 of the previous 12
dictionaries. A decent number of proper names is also included: The
top 1000 male, female, and Last names from the 1990 Census report; a
list of names sent to me by Alan Beale; and a few names that I added
myself. Finally a small list of abbreviations not commonly found in
other word lists is included.
The name files form the Census report is a government document which I
don't think can be copyrighted.
The file special-jargon.50 uses common.lst and word.lst from the
"Unofficial Jargon File Word Lists" which is derived from "The Jargon
File". All of which is in the Public Domain. This file also contain
a few extra UNIX terms which are found in the file "unix-terms" in the
special/ directory.
The 55 level includes words from Alan's 2of4brif list found in version
4.0 of his 12dicts package. Like his other stuff the 2of4brif is also
in the public domain.
The 60 level includes all words appearing in at least 2 of the 12
dictionaries as indicated by the 12Dicts package.
The 70 level includes Brian's frequency class 0 and the 74,550 common
dictionary words from the MWords package. The common dictionary words,
like those from the 12Dicts package, have had all likely inflections
added. The 70 level also included the 5desk list from version 4.0 of
the 12Dics package which is in the public domain.
The 80 level includes the ENABLE word list, all the lists in the
ENABLE supplement package (except for ABLE), the "UK Advanced Cryptics
Dictionary" (UKACD), the list of signature words from the YAWL package,
and the 10,196 places list from the MWords package.
The ENABLE package, mainted by M\Cooper <[email protected]>,
is in the Public Domain:
The ENABLE master word list, WORD.LST, is herewith formally released
into the Public Domain. Anyone is free to use it or distribute it in
any manner they see fit. No fee or registration is required for its
use nor are "contributions" solicited (if you feel you absolutely
must contribute something for your own peace of mind, the authors of
the ENABLE list ask that you make a donation on their behalf to your
favorite charity). This word list is our gift to the Scrabble
community, as an alternate to "official" word lists. Game designers
may feel free to incorporate the WORD.LST into their games. Please
mention the source and credit us as originators of the list. Note
that if you, as a game designer, use the WORD.LST in your product,
you may still copyright and protect your product, but you may *not*
legally copyright or in any way restrict redistribution of the
WORD.LST portion of your product. This *may* under law restrict your
rights to restrict your users' rights, but that is only fair.
UKACD, by J Ross Beresford <[email protected]>, is under the
following copyright:
Copyright (c) J Ross Beresford 1993-1999. All Rights Reserved.
The following restriction is placed on the use of this publication:
if The UK Advanced Cryptics Dictionary is used in a software package
or redistributed in any form, the copyright notice must be
prominently displayed and the text of this document must be included
verbatim.
There are no other restrictions: I would like to see the list
distributed as widely as possible.
The 95 level includes the 354,984 single words, 256,772 compound
words, 4,946 female names and the 3,897 male names, and 21,986 names
from the MWords package, ABLE.LST from the ENABLE Supplement, and some
additional words found in my part-of-speech database that were not
found anywhere else.
Accent information was taken from UKACD.
The VarCon package was used to create the American, British, Canadian,
and Australian word list. It is under the following copyright:
Copyright 2000-2016 by Kevin Atkinson
Permission to use, copy, modify, distribute and sell this array, the
associated software, and its documentation for any purpose is hereby
granted without fee, provided that the above copyright notice appears
in all copies and that both that copyright notice and this permission
notice appear in supporting documentation. Kevin Atkinson makes no
representations about the suitability of this array for any
purpose. It is provided "as is" without express or implied warranty.
Copyright 2016 by Benjamin Titze
Permission to use, copy, modify, distribute and sell this array, the
associated software, and its documentation for any purpose is hereby
granted without fee, provided that the above copyright notice appears
in all copies and that both that copyright notice and this permission
notice appear in supporting documentation. Benjamin Titze makes no
representations about the suitability of this array for any
purpose. It is provided "as is" without express or implied warranty.
Since the original words lists come from the Ispell distribution:
Copyright 1993, Geoff Kuenning, Granada Hills, CA
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. All modifications to the source code must be clearly marked as
such. Binary redistributions based on modified source code
must be clearly marked as modified versions in the documentation
and/or other materials provided with the distribution.
(clause 4 removed with permission from Geoff Kuenning)
5. The name of Geoff Kuenning may not be used to endorse or promote
products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
Build Date: Mon Dec 7 20:19:27 EST 2020
Wordlist Command: mk-list --accents=strip en_US 60
@@ -0,0 +1,205 @@
SET UTF-8
TRY esianrtolcdugmphbyfvkwzESIANRTOLCDUGMPHBYFVKWZ'
ICONV 1
ICONV ’ '
NOSUGGEST !
# ordinal numbers
COMPOUNDMIN 1
# only in compounds: 1th, 2th, 3th
ONLYINCOMPOUND c
# compound rules:
# 1. [0-9]*1[0-9]th (10th, 11th, 12th, 56714th, etc.)
# 2. [0-9]*[02-9](1st|2nd|3rd|[4-9]th) (21st, 22nd, 123rd, 1234th, etc.)
COMPOUNDRULE 2
COMPOUNDRULE n*1t
COMPOUNDRULE n*mp
WORDCHARS 0123456789
PFX A Y 1
PFX A 0 re .
PFX I Y 1
PFX I 0 in .
PFX U Y 1
PFX U 0 un .
PFX C Y 1
PFX C 0 de .
PFX E Y 1
PFX E 0 dis .
PFX F Y 1
PFX F 0 con .
PFX K Y 1
PFX K 0 pro .
SFX V N 2
SFX V e ive e
SFX V 0 ive [^e]
SFX N Y 3
SFX N e ion e
SFX N y ication y
SFX N 0 en [^ey]
SFX X Y 3
SFX X e ions e
SFX X y ications y
SFX X 0 ens [^ey]
SFX H N 2
SFX H y ieth y
SFX H 0 th [^y]
SFX Y Y 1
SFX Y 0 ly .
SFX G Y 2
SFX G e ing e
SFX G 0 ing [^e]
SFX J Y 2
SFX J e ings e
SFX J 0 ings [^e]
SFX D Y 4
SFX D 0 d e
SFX D y ied [^aeiou]y
SFX D 0 ed [^ey]
SFX D 0 ed [aeiou]y
SFX T N 4
SFX T 0 st e
SFX T y iest [^aeiou]y
SFX T 0 est [aeiou]y
SFX T 0 est [^ey]
SFX R Y 4
SFX R 0 r e
SFX R y ier [^aeiou]y
SFX R 0 er [aeiou]y
SFX R 0 er [^ey]
SFX Z Y 4
SFX Z 0 rs e
SFX Z y iers [^aeiou]y
SFX Z 0 ers [aeiou]y
SFX Z 0 ers [^ey]
SFX S Y 4
SFX S y ies [^aeiou]y
SFX S 0 s [aeiou]y
SFX S 0 es [sxzh]
SFX S 0 s [^sxzhy]
SFX P Y 3
SFX P y iness [^aeiou]y
SFX P 0 ness [aeiou]y
SFX P 0 ness [^y]
SFX M Y 1
SFX M 0 's .
SFX B Y 3
SFX B 0 able [^aeiou]
SFX B 0 able ee
SFX B e able [^aeiou]e
SFX L Y 1
SFX L 0 ment .
REP 90
REP a ei
REP ei a
REP a ey
REP ey a
REP ai ie
REP ie ai
REP alot a_lot
REP are air
REP are ear
REP are eir
REP air are
REP air ere
REP ere air
REP ere ear
REP ere eir
REP ear are
REP ear air
REP ear ere
REP eir are
REP eir ere
REP ch te
REP te ch
REP ch ti
REP ti ch
REP ch tu
REP tu ch
REP ch s
REP s ch
REP ch k
REP k ch
REP f ph
REP ph f
REP gh f
REP f gh
REP i igh
REP igh i
REP i uy
REP uy i
REP i ee
REP ee i
REP j di
REP di j
REP j gg
REP gg j
REP j ge
REP ge j
REP s ti
REP ti s
REP s ci
REP ci s
REP k cc
REP cc k
REP k qu
REP qu k
REP kw qu
REP o eau
REP eau o
REP o ew
REP ew o
REP oo ew
REP ew oo
REP ew ui
REP ui ew
REP oo ui
REP ui oo
REP ew u
REP u ew
REP oo u
REP u oo
REP u oe
REP oe u
REP u ieu
REP ieu u
REP ue ew
REP ew ue
REP uff ough
REP oo ieu
REP ieu oo
REP ier ear
REP ear ier
REP ear air
REP air ear
REP w qu
REP qu w
REP z ss
REP ss z
REP shun tion
REP shun sion
REP shun cion
REP size cise
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,474 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
0EC3DA4FFDC5C3B2B4E0B13B /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD7D0823B077104D6C806D11 /* MetalKit.framework */; };
380D17F67E636CBECF7E4E6D /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F2318AB597D2BA37D0450A64 /* WebKit.framework */; };
3E829B66804DD93D0CE82436 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C46540657F8D4E893AC66C7 /* QuartzCore.framework */; };
8261C76B0C8AAFFF0976BB94 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9B857C0B494A9C83F9F9115 /* Metal.framework */; };
89611C54325E79BBFCAF5A6F /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 623FD9F4898AE5FDE23B0A73 /* libapp.a */; };
911563A32C960AC8241ED351 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8CC5D253EF80093EC54CD51D /* Assets.xcassets */; };
D6FCF60E9A225E886B4201B4 /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = D7C0C2B726053A491DE0A492 /* main.mm */; };
E9F56517F64D408654A47123 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 877554CC1AAF0ED8612C473F /* Security.framework */; };
EA15997B9A4564CD88AAF265 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AC3E0782AC1DE69EC1393F4F /* LaunchScreen.storyboard */; };
F1736708F9507F3358D2FB10 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 360E99CA5BCBFEBC9E48DB30 /* CoreGraphics.framework */; };
F6530048C395DF030B4AB269 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = E4E3950AAF9CFB9F61092532 /* assets */; };
F75C7FAF5CEEF9BE332DC814 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AEE5851B77FD26F3F57404A0 /* UIKit.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
14817B3F82D600114D013B7D /* pdf.rs */ = {isa = PBXFileReference; path = pdf.rs; sourceTree = "<group>"; };
277A822D18EFBF6E79B2EFC6 /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
2C46540657F8D4E893AC66C7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
360E99CA5BCBFEBC9E48DB30 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
448CDA24E192A9EDE84EE2F6 /* library.rs */ = {isa = PBXFileReference; path = library.rs; sourceTree = "<group>"; };
623FD9F4898AE5FDE23B0A73 /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
6A0CF179B6206602B684ACC5 /* margin-app_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "margin-app_iOS.entitlements"; sourceTree = "<group>"; };
769526E10F6C7273A9421972 /* epub.rs */ = {isa = PBXFileReference; path = epub.rs; sourceTree = "<group>"; };
832AEF636C465719EE30629D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
877554CC1AAF0ED8612C473F /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
8C696B0612077DC32CFD7281 /* proofing.rs */ = {isa = PBXFileReference; path = proofing.rs; sourceTree = "<group>"; };
8CC5D253EF80093EC54CD51D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
AC3E0782AC1DE69EC1393F4F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
AEE5851B77FD26F3F57404A0 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
B94688F7EA7A2C4A8C0DFD01 /* margin-app_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "margin-app_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
C018CB1E589580D894AB7585 /* gdrive.rs */ = {isa = PBXFileReference; path = gdrive.rs; sourceTree = "<group>"; };
D33920AF504282488CBE64EF /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
D74A9059FCE11D1A08EC6511 /* project.rs */ = {isa = PBXFileReference; path = project.rs; sourceTree = "<group>"; };
D7C0C2B726053A491DE0A492 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
DD7D0823B077104D6C806D11 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
E4E3950AAF9CFB9F61092532 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; };
F2318AB597D2BA37D0450A64 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
F8B65F0038A8DDF9CE7C2310 /* fonts.rs */ = {isa = PBXFileReference; path = fonts.rs; sourceTree = "<group>"; };
F95681711AA6F4A228A97009 /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
F9B857C0B494A9C83F9F9115 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
AACF50BDF8AA677AF9F50383 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
89611C54325E79BBFCAF5A6F /* libapp.a in Frameworks */,
F1736708F9507F3358D2FB10 /* CoreGraphics.framework in Frameworks */,
8261C76B0C8AAFFF0976BB94 /* Metal.framework in Frameworks */,
0EC3DA4FFDC5C3B2B4E0B13B /* MetalKit.framework in Frameworks */,
3E829B66804DD93D0CE82436 /* QuartzCore.framework in Frameworks */,
E9F56517F64D408654A47123 /* Security.framework in Frameworks */,
F75C7FAF5CEEF9BE332DC814 /* UIKit.framework in Frameworks */,
380D17F67E636CBECF7E4E6D /* WebKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1F4A93A133625BBBC5DD173D /* margin-app */ = {
isa = PBXGroup;
children = (
D7C0C2B726053A491DE0A492 /* main.mm */,
E1DE2A12D42E2CA3B48BF39C /* bindings */,
);
path = "margin-app";
sourceTree = "<group>";
};
603E19204F4A400DBF3BDC47 /* Frameworks */ = {
isa = PBXGroup;
children = (
360E99CA5BCBFEBC9E48DB30 /* CoreGraphics.framework */,
623FD9F4898AE5FDE23B0A73 /* libapp.a */,
F9B857C0B494A9C83F9F9115 /* Metal.framework */,
DD7D0823B077104D6C806D11 /* MetalKit.framework */,
2C46540657F8D4E893AC66C7 /* QuartzCore.framework */,
877554CC1AAF0ED8612C473F /* Security.framework */,
AEE5851B77FD26F3F57404A0 /* UIKit.framework */,
F2318AB597D2BA37D0450A64 /* WebKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
AF74A9A4E2E249230D0E350E /* Products */ = {
isa = PBXGroup;
children = (
B94688F7EA7A2C4A8C0DFD01 /* margin-app_iOS.app */,
);
name = Products;
sourceTree = "<group>";
};
B3F300FBBFBCCA3CA7B12D3D /* src */ = {
isa = PBXGroup;
children = (
769526E10F6C7273A9421972 /* epub.rs */,
F8B65F0038A8DDF9CE7C2310 /* fonts.rs */,
C018CB1E589580D894AB7585 /* gdrive.rs */,
F95681711AA6F4A228A97009 /* lib.rs */,
448CDA24E192A9EDE84EE2F6 /* library.rs */,
D33920AF504282488CBE64EF /* main.rs */,
14817B3F82D600114D013B7D /* pdf.rs */,
D74A9059FCE11D1A08EC6511 /* project.rs */,
8C696B0612077DC32CFD7281 /* proofing.rs */,
);
name = src;
path = ../../src;
sourceTree = "<group>";
};
C49F8FAE68067D0E047F17C1 /* margin-app_iOS */ = {
isa = PBXGroup;
children = (
832AEF636C465719EE30629D /* Info.plist */,
6A0CF179B6206602B684ACC5 /* margin-app_iOS.entitlements */,
);
path = "margin-app_iOS";
sourceTree = "<group>";
};
D99826F6BEBF021E86B50785 = {
isa = PBXGroup;
children = (
E4E3950AAF9CFB9F61092532 /* assets */,
8CC5D253EF80093EC54CD51D /* Assets.xcassets */,
AC3E0782AC1DE69EC1393F4F /* LaunchScreen.storyboard */,
FF76185D65C96C10997F4EC9 /* Externals */,
C49F8FAE68067D0E047F17C1 /* margin-app_iOS */,
F62D42C7D88CAD0BB5707700 /* Sources */,
B3F300FBBFBCCA3CA7B12D3D /* src */,
603E19204F4A400DBF3BDC47 /* Frameworks */,
AF74A9A4E2E249230D0E350E /* Products */,
);
sourceTree = "<group>";
};
E1DE2A12D42E2CA3B48BF39C /* bindings */ = {
isa = PBXGroup;
children = (
277A822D18EFBF6E79B2EFC6 /* bindings.h */,
);
path = bindings;
sourceTree = "<group>";
};
F62D42C7D88CAD0BB5707700 /* Sources */ = {
isa = PBXGroup;
children = (
1F4A93A133625BBBC5DD173D /* margin-app */,
);
path = Sources;
sourceTree = "<group>";
};
FF76185D65C96C10997F4EC9 /* Externals */ = {
isa = PBXGroup;
children = (
);
path = Externals;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
D914A04EE7625E0BFF302CBB /* margin-app_iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = 2AFB5AED9BBDA36D01ECA7AE /* Build configuration list for PBXNativeTarget "margin-app_iOS" */;
buildPhases = (
AB760B90696AB2964C7D49A9 /* Build Rust Code */,
DE9CC46A3C745D3EE2659FC1 /* Sources */,
303B1D89A72F4ED6D2E12CFD /* Resources */,
AACF50BDF8AA677AF9F50383 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "margin-app_iOS";
packageProductDependencies = (
);
productName = "margin-app_iOS";
productReference = B94688F7EA7A2C4A8C0DFD01 /* margin-app_iOS.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
5FC750CFBA18ED6763EEF3A3 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
};
};
buildConfigurationList = C4AD3401C458C5C48B1EF849 /* Build configuration list for PBXProject "margin-app" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = D99826F6BEBF021E86B50785;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = AF74A9A4E2E249230D0E350E /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
D914A04EE7625E0BFF302CBB /* margin-app_iOS */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
303B1D89A72F4ED6D2E12CFD /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
911563A32C960AC8241ED351 /* Assets.xcassets in Resources */,
EA15997B9A4564CD88AAF265 /* LaunchScreen.storyboard in Resources */,
F6530048C395DF030B4AB269 /* assets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
AB760B90696AB2964C7D49A9 /* Build Rust Code */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build Rust Code";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a",
"$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "npm run -- tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
DE9CC46A3C745D3EE2659FC1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6FCF60E9A225E886B4201B4 /* main.mm in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
2115AE8BB6330806DC18A6DE /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "margin-app_iOS/margin-app_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "margin-app_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = studio.margin.app;
PRODUCT_NAME = "Margin";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = release;
};
44B57C3232BEF013CE90639D /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = debug;
};
845C6F72D84890D3B5F7D222 /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = release;
};
E3805913695CB6F5D2BE9BCB /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "margin-app_iOS/margin-app_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "margin-app_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = studio.margin.app;
PRODUCT_NAME = "Margin";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
2AFB5AED9BBDA36D01ECA7AE /* Build configuration list for PBXNativeTarget "margin-app_iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E3805913695CB6F5D2BE9BCB /* debug */,
2115AE8BB6330806DC18A6DE /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
C4AD3401C458C5C48B1EF849 /* Build configuration list for PBXProject "margin-app" */ = {
isa = XCConfigurationList;
buildConfigurations = (
44B57C3232BEF013CE90639D /* debug */,
845C6F72D84890D3B5F7D222 /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
/* End XCConfigurationList section */
};
rootObject = 5FC750CFBA18ED6763EEF3A3 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildSystemType</key>
<string>Original</string>
<key>DisableBuildSystemDeprecationDiagnostic</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</TestAction>
<LaunchAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "release"
shouldUseLaunchSchemeArgsEnv = "NO"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D914A04EE7625E0BFF302CBB"
BuildableName = "margin-app_iOS.app"
BlueprintName = "margin-app_iOS"
ReferencedContainer = "container:margin-app.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.7</string>
<key>CFBundleVersion</key>
<string>0.1.7</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>metal</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
+88
View File
@@ -0,0 +1,88 @@
name: margin-app
options:
bundleIdPrefix: studio.margin.app
deploymentTarget:
iOS: 14.0
fileGroups: [../../src]
configs:
debug: debug
release: release
settingGroups:
app:
base:
PRODUCT_NAME: Margin
PRODUCT_BUNDLE_IDENTIFIER: studio.margin.app
targetTemplates:
app:
type: application
sources:
- path: Sources
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
groups: [app]
targets:
margin-app_iOS:
type: application
platform: iOS
sources:
- path: Sources
- path: Assets.xcassets
- path: Externals
- path: margin-app_iOS
- path: assets
buildPhase: resources
type: folder
- path: LaunchScreen.storyboard
info:
path: margin-app_iOS/Info.plist
properties:
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
UIRequiredDeviceCapabilities: [arm64, metal]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: 0.1.7
CFBundleVersion: "0.1.7"
entitlements:
path: margin-app_iOS/margin-app_iOS.entitlements
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
base:
ENABLE_BITCODE: false
ARCHS: [arm64]
VALID_ARCHS: arm64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
groups: [app]
dependencies:
- framework: libapp.a
embed: false
- sdk: CoreGraphics.framework
- sdk: Metal.framework
- sdk: MetalKit.framework
- sdk: QuartzCore.framework
- sdk: Security.framework
- sdk: UIKit.framework
- sdk: WebKit.framework
preBuildScripts:
- script: npm run -- tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
name: Build Rust Code
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
@@ -0,0 +1,304 @@
# Bundled technical and product terms accepted by the spell checker.
# Matched case-insensitively. Words with internal capitals (macOS, iOS,
# iPhone, GitHub) are already accepted by a heuristic and need not be listed.
# Platforms and operating systems
ios
ipados
macos
watchos
tvos
android
linux
unix
ubuntu
debian
fedora
centos
distro
kernel
chromeos
# Apple ecosystem
iphone
ipad
ipod
imac
macbook
airpods
airpod
airtag
homepod
icloud
airdrop
facetime
imessage
siri
retina
# Devices and hardware
smartphone
smartphones
smartwatch
touchscreen
webcam
microcontroller
cpu
gpu
ssd
hdd
ram
usb
hdmi
ethernet
bluetooth
wifi
nfc
# Languages and runtimes
javascript
typescript
nodejs
node
deno
bun
golang
kotlin
swift
rust
ruby
python
php
perl
scala
clojure
elixir
haskell
dart
lua
# Tooling and package managers
npm
npx
nvm
yarn
pnpm
webpack
vite
rollup
esbuild
babel
eslint
prettier
tsconfig
linter
linters
transpiler
bundler
runtime
runtimes
toolchain
boilerplate
# Web and frontend
frontend
backend
fullstack
middleware
serverless
api
apis
endpoint
endpoints
url
urls
uri
uris
cors
oauth
jwt
csrf
xss
favicon
viewport
responsive
dropdown
checkbox
tooltip
modal
navbar
sidebar
toolbar
wireframe
mockup
placeholder
# Data formats and protocols
json
yaml
toml
xml
svg
csv
markdown
http
https
ssh
ftp
sftp
tcp
udp
dns
vpn
ssl
tls
localhost
hostname
subdomain
namespace
# Databases
sql
nosql
postgres
postgresql
mysql
mariadb
sqlite
mongodb
redis
elasticsearch
schema
schemas
schemaless
indexing
# DevOps and cloud
docker
dockerfile
kubernetes
nginx
serverless
devops
ci
cd
pipeline
pipelines
deploy
deployment
rollback
autoscaling
balancer
microservice
microservices
container
containers
orchestration
# Cloud providers and services
aws
gcp
azure
cloudflare
vercel
netlify
heroku
firebase
supabase
digitalocean
# Version control and collaboration
git
github
gitlab
bitbucket
repo
repos
monorepo
changelog
commit
commits
rebase
merge
codebase
hotfix
changeset
# Programming concepts
async
await
callback
callbacks
boolean
enum
struct
regex
stdin
stdout
stderr
config
configs
metadata
dataset
datasets
workflow
workflows
plugin
plugins
framework
frameworks
sdk
cli
gui
ide
debugger
breakpoint
recursion
memoization
multithreading
serialization
deserialization
preprocessor
middleware
# AI and data
chatgpt
openai
anthropic
llm
llms
chatbot
chatbots
embeddings
tokenization
finetuning
inference
# Web3 and crypto
blockchain
bitcoin
ethereum
crypto
cryptocurrency
nft
nfts
# Product and UX
ux
ui
onboarding
login
logout
signup
username
usernames
changelog
screenshot
screenshots
emoji
emojis
hashtag
hashtags
clickable
scrollable
swipeable
-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())
}
+33 -5
View File
@@ -1,12 +1,17 @@
mod epub;
mod fonts;
mod gdrive;
mod library;
mod pdf;
mod project;
mod proofing;
#[cfg(desktop)]
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
#[cfg(desktop)]
use tauri::{Emitter, Runtime};
#[cfg(desktop)]
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
let menu = Menu::default(handle)?;
let new_book = MenuItemBuilder::with_id("new-book", "New Book")
@@ -60,17 +65,30 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
pub fn run() {
let context = tauri::generate_context!();
#[cfg_attr(mobile, allow(unused_mut))]
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init());
.plugin(tauri_plugin_dialog::init());
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_process::init());
if context.config().plugins.0.contains_key("updater") {
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
}
}
builder
builder = builder
.manage(proofing::new_state())
.manage(gdrive::GDriveState::default())
.setup(|app| {
gdrive::init_session(app.handle());
Ok(())
});
#[cfg(desktop)]
{
builder = builder
.menu(|handle| build_menu(handle))
.on_menu_event(|app, event| {
if matches!(
@@ -79,10 +97,14 @@ pub fn run() {
) {
app.emit("menu-action", event.id().0.as_str()).ok();
}
})
});
}
builder
.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 +114,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");
+40 -12
View File
@@ -7,18 +7,37 @@ pub struct BookSummary {
id: String,
title: String,
author: String,
corrupt: bool,
}
fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("library");
fn corrupt_summary(stem: &str) -> BookSummary {
BookSummary {
id: stem.to_string(),
title: "Unreadable book".to_string(),
author: String::new(),
corrupt: true,
}
}
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
pub(crate) fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app_data_dir(app)?.join("library");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
fn book_path(app: &tauri::AppHandle, id: &str) -> Result<PathBuf, String> {
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err("invalid book id".to_string());
}
Ok(library_dir(app)?.join(format!("{id}.margin")))
}
#[tauri::command]
pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
let dir = library_dir(&app)?;
@@ -31,16 +50,24 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
continue;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(_) => continue,
Err(_) => {
books.push(corrupt_summary(stem));
continue;
}
};
let value: serde_json::Value = match serde_json::from_str(&contents) {
Ok(value) => value,
Err(_) => continue,
Err(_) => {
books.push(corrupt_summary(stem));
continue;
}
};
let id = value.get("id").and_then(|v| v.as_str()).unwrap_or("");
if id.is_empty() {
books.push(corrupt_summary(stem));
continue;
}
let metadata = value.get("metadata");
@@ -58,6 +85,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
id: id.to_string(),
title,
author,
corrupt: false,
});
}
Ok(books)
@@ -65,19 +93,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
#[tauri::command]
pub fn load_book(app: tauri::AppHandle, id: String) -> Result<String, String> {
let path = library_dir(&app)?.join(format!("{id}.margin"));
let path = book_path(&app, &id)?;
fs::read_to_string(&path).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> {
let path = library_dir(&app)?.join(format!("{id}.margin"));
fs::write(&path, contents).map_err(|e| e.to_string())
let path = book_path(&app, &id)?;
crate::project::atomic_write(&path, contents.as_bytes(), true)
}
#[tauri::command]
pub fn delete_book(app: tauri::AppHandle, id: String) -> Result<(), String> {
let path = library_dir(&app)?.join(format!("{id}.margin"));
let path = book_path(&app, &id)?;
if path.exists() {
fs::remove_file(&path).map_err(|e| e.to_string())?;
}
+70 -8
View File
@@ -1,7 +1,8 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde::Deserialize;
use typst::diag::{Severity, SourceDiagnostic};
use tauri::Emitter;
use typst::diag::{Severity, SourceDiagnostic, Warned};
use typst::layout::PagedDocument;
use typst_as_lib::TypstEngine;
@@ -9,6 +10,29 @@ static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
static 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 {
@@ -16,7 +40,12 @@ pub struct ImageInput {
data: String,
}
fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
fn compile(
source: String,
images: &[ImageInput],
bundled_fonts: &[String],
system_fonts: &[String],
) -> Result<(Vec<u8>, String), String> {
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
for image in images {
let bytes = STANDARD
@@ -25,20 +54,53 @@ fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
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();
let document: PagedDocument = engine.compile().output.map_err(|e| format_diagnostics(&e))?;
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))
let Warned { output, warnings } = engine.compile();
let document: PagedDocument = output.map_err(|e| format_diagnostics(&e))?;
let bytes =
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))?;
let warning_text = if warnings.is_empty() {
String::new()
} else {
format_source_diagnostics(&warnings)
};
Ok((bytes, warning_text))
}
#[tauri::command(async)]
pub fn compile_pdf(source: String, images: Vec<ImageInput>) -> Result<tauri::ipc::Response, String> {
compile(source, &images).map(tauri::ipc::Response::new)
pub fn compile_pdf(
app: tauri::AppHandle,
source: String,
images: Vec<ImageInput>,
emit_warnings: bool,
bundled_fonts: Vec<String>,
system_fonts: Vec<String>,
) -> Result<tauri::ipc::Response, String> {
let (bytes, warnings) = compile(source, &images, &bundled_fonts, &system_fonts)?;
if emit_warnings && !warnings.is_empty() {
app.emit("pdf-warnings", warnings).ok();
}
Ok(tauri::ipc::Response::new(bytes))
}
fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String {
+30 -3
View File
@@ -1,6 +1,33 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use std::fs;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(suffix);
PathBuf::from(name)
}
pub fn atomic_write(path: &Path, bytes: &[u8], backup: bool) -> Result<(), String> {
let tmp = with_suffix(path, ".tmp");
{
let mut f = File::create(&tmp).map_err(|e| e.to_string())?;
f.write_all(bytes).map_err(|e| e.to_string())?;
f.sync_all().map_err(|e| e.to_string())?;
}
if path.exists() {
if backup {
let bak = with_suffix(path, ".bak");
let _ = fs::remove_file(&bak);
fs::rename(path, &bak).map_err(|e| e.to_string())?;
} else {
fs::remove_file(path).map_err(|e| e.to_string())?;
}
}
fs::rename(&tmp, path).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn read_file(path: String) -> Result<String, String> {
@@ -9,11 +36,11 @@ pub fn read_file(path: String) -> Result<String, String> {
#[tauri::command]
pub fn write_file(path: String, contents: String) -> Result<(), String> {
fs::write(&path, contents).map_err(|e| e.to_string())
atomic_write(Path::new(&path), contents.as_bytes(), true)
}
#[tauri::command]
pub fn write_bytes(path: String, data: String) -> Result<(), String> {
let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_string())?;
fs::write(&path, bytes).map_err(|e| e.to_string())
atomic_write(Path::new(&path), &bytes, false)
}
+16 -4
View File
@@ -59,11 +59,17 @@ fn read_words(contents: &str, set: &mut HashSet<String>) {
for line in contents.lines() {
let word = line.trim();
if !word.is_empty() && !word.starts_with('#') {
set.insert(word.to_string());
set.insert(word.to_lowercase());
}
}
}
fn load_tech(app: &tauri::AppHandle, set: &mut HashSet<String>) {
if let Ok(contents) = dict_file(app, "tech.txt") {
read_words(&contents, set);
}
}
fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
let mut set = HashSet::new();
if let Ok(path) = custom_dict_path(app) {
@@ -98,6 +104,10 @@ fn is_word_char(c: char) -> bool {
c.is_alphabetic()
}
fn has_irregular_case(word: &str) -> bool {
word.chars().skip(1).any(|c| c.is_uppercase())
}
fn is_apostrophe(c: char) -> bool {
c == '\'' || c == '\u{2019}'
}
@@ -128,7 +138,7 @@ fn collect_spelling(
}
let end = i;
let word: String = chars[start..end].iter().collect();
if speller.check(&word) || custom.contains(&word) {
if has_irregular_case(&word) || speller.check(&word) || custom.contains(&word.to_lowercase()) {
continue;
}
let mut suggestions = Vec::new();
@@ -184,10 +194,12 @@ pub async fn proof_text(
let state = app.state::<ProofState>();
let mut guard = state.lock().map_err(|e| e.to_string())?;
if guard.is_none() {
let mut custom = load_custom(&app);
load_tech(&app, &mut custom);
*guard = Some(Engine {
speller: None,
harper: None,
custom: load_custom(&app),
custom,
});
}
let engine = guard.as_mut().unwrap();
@@ -232,7 +244,7 @@ pub fn remember_word(
let mut guard = state.inner().lock().map_err(|e| e.to_string())?;
if let Some(engine) = guard.as_mut() {
engine.custom.insert(word);
engine.custom.insert(word.to_lowercase());
}
Ok(())
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "margin",
"version": "0.1.3",
"productName": "Margin",
"version": "0.1.8",
"identifier": "studio.margin.app",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -21,7 +21,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; img-src 'self' data: blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; connect-src 'self' ipc: http://ipc.localhost"
}
},
"bundle": {
+65 -4
View File
@@ -1,10 +1,15 @@
import { useEffect } from "react";
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 { ExportPreview } from "./components/ExportPreview";
import { useBook } from "./store/useBook";
import { useBackup } from "./store/useBackup";
import { useExportPreview } from "./store/useExportPreview";
import { isDesktop } from "./ipc";
import { newBook } from "./library";
import { createAndOpenBook, saveBook } from "./library";
import { runExport } from "./export/run";
import { checkForUpdates } from "./updater";
@@ -17,17 +22,73 @@ function App() {
checkForUpdates(true);
const unlisten = listen<string>("menu-action", (event) => {
const state = useBook.getState();
if (event.payload === "new-book") state.openBook(newBook());
else if (event.payload === "export-pdf") runExport("pdf");
if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice);
else if (event.payload === "export-pdf") {
if (useBook.getState().book) useExportPreview.getState().openPreview();
}
else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false);
});
const unlistenWarn = listen<string>("pdf-warnings", (event) => {
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
});
return () => {
unlisten.then((stop) => stop());
unlistenWarn.then((stop) => stop());
};
}, []);
useEffect(() => {
if (!isDesktop) return;
const win = getCurrentWindow();
const unlisten = win.onCloseRequested(async (event) => {
const { book, dirty } = useBook.getState();
const { connected } = useBackup.getState();
if ((!book || !dirty) && !connected) return;
event.preventDefault();
if (book && dirty) await saveBook(book).catch(() => {});
if (connected) {
await Promise.race([
useBackup.getState().backup(true),
new Promise((resolve) => setTimeout(resolve, 8000)),
]);
}
win.destroy();
});
return () => {
unlisten.then((stop) => stop());
};
}, []);
return book ? <EditorView /> : <Library onOpen={openBook} />;
useEffect(() => {
if (!isDesktop) return;
const unlisten = listen<{ ok: boolean; error: string | null }>("gdrive-auth", (event) => {
useBackup.getState().handleAuthEvent(event.payload.ok, event.payload.error);
});
return () => {
unlisten.then((stop) => stop());
};
}, []);
useEffect(() => {
if (!isDesktop) return;
const tick = async () => {
await useBackup.getState().refresh();
const state = useBackup.getState();
if (state.connected && state.pending) state.backup(true);
};
tick();
const id = setInterval(tick, 15 * 60 * 1000);
return () => clearInterval(id);
}, []);
return (
<>
{book ? <EditorView /> : <Library onOpen={openBook} />}
{isDesktop && <BackupSettings />}
{isDesktop && <ExportPreview />}
</>
);
}
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");
}
+13 -1
View File
@@ -4,6 +4,7 @@ import { Icon } from "./Icon";
interface AddPageMenuProps {
onAdd: (group: "front" | "back", title: string) => void;
onAddPart: () => void;
}
const GROUPS: { key: "front" | "back"; label: string }[] = [
@@ -11,7 +12,7 @@ const GROUPS: { key: "front" | "back"; label: string }[] = [
{ key: "back", label: "Back matter" },
];
export function AddPageMenu({ onAdd }: AddPageMenuProps) {
export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
@@ -42,6 +43,11 @@ export function AddPageMenu({ onAdd }: AddPageMenuProps) {
onAdd(group, label);
};
const pickPart = () => {
setOpen(false);
onAddPart();
};
return (
<>
<button ref={btnRef} className="add-page" data-open={open} onClick={toggle}>
@@ -54,6 +60,12 @@ export function AddPageMenu({ onAdd }: AddPageMenuProps) {
style={{ top: coords.top, left: coords.left }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="add-page-group">
<div className="add-page-label">Body</div>
<button className="add-page-item" onClick={pickPart}>
Part divider
</button>
</div>
{GROUPS.map((g) => (
<div key={g.key} className="add-page-group">
<div className="add-page-label">{g.label}</div>
+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)}
/>
)}
</>
);
}
+14 -6
View File
@@ -1,7 +1,7 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { generateHTML } from "@tiptap/core";
import { COVER_ID, useBook } from "../store/useBook";
import { type Chapter, type TrimSize, bodyNumber, chapterKind } from "../model/book";
import { type Chapter, type TrimSize, bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { editorExtensions } from "../editor/extensions";
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
import { compilePdf, isDesktop } from "../ipc";
@@ -71,7 +71,15 @@ function DockHead({ meta }: { meta?: ReactNode }) {
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
const kind = chapterKind(chapter);
return kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
if (kind === "body") return `Chapter ${bodyNumber(chapters, idx) ?? ""}`;
if (kind === "part") return `Part ${partRoman(partNumber(chapters, idx) ?? 0)}`;
return kind === "front" ? "Front matter" : "Back matter";
}
function previewTitle(chapter: Chapter): string | undefined {
if (chapter.noTitle) return undefined;
if (chapterKind(chapter) === "part") return chapter.title || undefined;
return chapter.title || "Untitled";
}
function useActiveChapter() {
@@ -107,8 +115,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);
@@ -163,7 +171,7 @@ function HtmlDock() {
<div className="page">
<div className="p-opener">
<div className="p-num">{eyebrow}</div>
{!chapter.noTitle && <div className="p-title">{chapter.title || "Untitled"}</div>}
{previewTitle(chapter) !== undefined && <div className="p-title">{previewTitle(chapter)}</div>}
</div>
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
<div className="folio">{idx * 8 + 7}</div>
@@ -207,7 +215,7 @@ function DeviceDock({ device }: { device: Device }) {
<DeviceFrame
device={device}
eyebrow={eyebrow}
title={chapter.noTitle ? undefined : chapter.title || "Untitled"}
title={previewTitle(chapter)}
html={html}
/>
</section>
+171 -20
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Editor as TiptapEditor } from "@tiptap/react";
import { Sidebar } from "./Sidebar";
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,13 +13,16 @@ 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 { useExportPreview } from "../store/useExportPreview";
import { useProofing } from "../store/useProofing";
import { useTheme } from "../store/useTheme";
import { useWidth } from "../store/useWidth";
import { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind } from "../model/book";
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { saveBook } from "../library";
import { isDesktop } from "../ipc";
import { useCompact } from "../useMedia";
import { issueSignature, rememberWord, runProof } from "../proofing";
import { runExport } from "../export/run";
@@ -38,10 +42,13 @@ export function EditorView() {
const width = useWidth((s) => s.width);
const setWidth = useWidth((s) => s.setWidth);
const isCompact = useCompact();
const [editor, setEditor] = useState<TiptapEditor | null>(null);
const [dock, setDock] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
const [dock, setDock] = useState(!isCompact);
const [settingsOpen, setSettingsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [widthOpen, setWidthOpen] = useState(false);
const [findOpen, setFindOpen] = useState(false);
const [findExpanded, setFindExpanded] = useState(false);
@@ -56,6 +63,21 @@ export function EditorView() {
const coverActive = activeChapterId === COVER_ID;
const proofingAvailable = isDesktop && book?.metadata.language === "en";
const toggleSidebar = () =>
setSidebarOpen((open) => {
if (!open && isCompact) setDock(false);
return !open;
});
const toggleDock = () =>
setDock((open) => {
if (!open && isCompact) setSidebarOpen(false);
return !open;
});
const closeDrawers = () => {
setSidebarOpen(false);
setDock(false);
};
const openFind = useCallback((expanded: boolean) => {
setFindExpanded(expanded);
setFindOpen(true);
@@ -75,8 +97,14 @@ export function EditorView() {
const saveNow = useCallback(() => {
const current = useBook.getState().book;
if (current) saveBook(current).then(markSaved).catch((e) => setNotice(`Save failed: ${e}`));
}, [markSaved]);
if (!current) return;
saveBook(current)
.then(() => {
if (useBook.getState().book === current) markSaved();
useBackup.getState().refresh();
})
.catch((e) => setNotice(`Save failed: ${e}`));
}, [markSaved, setNotice]);
useEffect(() => {
if (!book || !dirty || !isDesktop) return;
@@ -104,6 +132,11 @@ export function EditorView() {
return () => clearTimeout(timer);
}, [notice]);
useEffect(() => {
setSidebarOpen(!isCompact);
setDock(!isCompact);
}, [isCompact]);
useEffect(() => {
if (!editor) return;
const storage = (editor.storage as unknown as Record<string, ProofingStorage>).proofing;
@@ -139,7 +172,8 @@ export function EditorView() {
const handleExport = (format: "pdf" | "epub") => {
setExportOpen(false);
runExport(format);
if (format === "pdf" && isDesktop) useExportPreview.getState().openPreview();
else runExport(format);
};
if (!book) return null;
@@ -148,20 +182,32 @@ export function EditorView() {
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
const kind = chapter ? chapterKind(chapter) : "body";
const eyebrow =
kind === "body" ? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
kind === "body"
? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}`
: kind === "part"
? `Part ${partRoman(partNumber(book.chapters, realIdx) ?? 0)}`
: kind === "front"
? "Front matter"
: "Back matter";
return (
<div className="app">
<div className="app" data-compact={isCompact} data-sidebar={sidebarOpen} data-dock={dock}>
<header className="titlebar" data-tauri-drag-region>
<div className="lead">
<button className="icon-btn" data-on={sidebarOpen} onClick={toggleSidebar} title="Toggle chapters" aria-label="Toggle chapters">
<Icon d="M3 4.5h18v15H3zM9 4.5v15" />
</button>
</div>
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
{book.metadata.title || "Untitled"}
{dirty && <span className="dirty-dot" />}
</button>
<div className="actions">
{isDesktop && !isCompact && <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>
{proofingAvailable && !coverActive && (
{!isCompact && proofingAvailable && !coverActive && (
<>
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
<Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
@@ -171,6 +217,7 @@ export function EditorView() {
</button>
</>
)}
{!isCompact && (
<div className="menu-wrap">
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
@@ -195,6 +242,8 @@ export function EditorView() {
</>
)}
</div>
)}
{!isCompact && (
<div className="menu-wrap">
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
@@ -209,6 +258,8 @@ export function EditorView() {
</>
)}
</div>
)}
{!isCompact && (
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
{theme === "dark" ? (
<Icon>
@@ -219,9 +270,67 @@ export function EditorView() {
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
)}
</button>
<button className="icon-btn" data-on={dock} onClick={() => setDock(!dock)} title="Toggle preview">
)}
<button className="icon-btn" data-on={dock} onClick={toggleDock} title="Toggle preview">
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
</button>
{isCompact && (
<div className="menu-wrap">
<button className="icon-btn" data-on={moreOpen} onClick={() => setMoreOpen((v) => !v)} title="More" aria-label="More options">
<Icon d="M5 12h.01M12 12h.01M19 12h.01" />
</button>
{moreOpen && (
<>
<div className="menu-backdrop" onClick={() => setMoreOpen(false)} />
<div className="menu">
<div className="menu-label">Editor width</div>
{WIDTH_OPTIONS.map((w) => (
<button
key={w.id}
data-on={width === w.id}
onClick={() => {
setWidth(w.id);
setMoreOpen(false);
}}
>
{w.label}
</button>
))}
<div className="menu-sep" />
<button
onClick={() => {
setMoreOpen(false);
handleExport("pdf");
}}
>
Export PDF…
</button>
<button
onClick={() => {
setMoreOpen(false);
handleExport("epub");
}}
>
Export EPUB…
</button>
{proofingAvailable && !coverActive && (
<>
<div className="menu-sep" />
<button data-on={spelling} onClick={toggleSpelling}>
Check spelling
</button>
<button data-on={grammar} onClick={toggleGrammar}>
Check grammar
</button>
</>
)}
<div className="menu-sep" />
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
</div>
</>
)}
</div>
)}
</div>
</header>
@@ -233,8 +342,8 @@ export function EditorView() {
/>
<div className="body">
<Sidebar />
<ResizeHandle pane="sidebar" />
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} />
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
<main className="editor-pane">
{coverActive ? (
<CoverView />
@@ -243,13 +352,13 @@ export function EditorView() {
<article className="sheet">
<header className="chapter-opener">
<div className="chapter-num">{eyebrow}</div>
<input
className="chapter-title-input"
value={chapter.noTitle ? "" : chapter.title}
placeholder={chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : "Page title"}
spellCheck={false}
disabled={chapter.noTitle}
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
<ChapterTitleInput
value={chapter.title}
placeholder={
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
}
disabled={!!chapter.noTitle}
onChange={(value) => setChapterTitle(chapter.id, value)}
/>
{(chapter.noTitle || !chapter.title.trim()) && (
<label className="no-title-check">
@@ -263,19 +372,26 @@ export function EditorView() {
)}
</header>
<Editor
key={chapter.id}
bookId={book.id}
chapterId={chapter.id}
content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)}
onReady={setEditor}
onContentError={() =>
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
}
/>
</article>
<FloatingToolbar editor={editor} />
</>
)}
</main>
{dock && <ResizeHandle pane="dock" />}
{dock && !isCompact && <ResizeHandle pane="dock" />}
{dock && <Dock />}
{isCompact && (sidebarOpen || dock) && (
<button className="drawer-scrim" aria-label="Close panel" onClick={closeDrawers} />
)}
</div>
{proofPopover && editor && (
@@ -315,3 +431,38 @@ export function EditorView() {
</div>
);
}
function ChapterTitleInput({
value,
placeholder,
disabled,
onChange,
}: {
value: string;
placeholder: string;
disabled: boolean;
onChange: (value: string) => void;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [value, disabled]);
return (
<textarea
ref={ref}
className="chapter-title-input"
rows={1}
value={value}
placeholder={placeholder}
spellCheck={false}
disabled={disabled}
onKeyDown={(e) => e.key === "Enter" && e.preventDefault()}
onChange={(e) => onChange(e.target.value)}
/>
);
}
+339
View File
@@ -0,0 +1,339 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { useBook } from "../store/useBook";
import { useExportPreview } from "../store/useExportPreview";
import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
import { compilePdf } from "../ipc";
import { saveBytes } from "../project";
import { Icon } from "./Icon";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
const PDF_FILTER = [{ name: "PDF", extensions: ["pdf"] }];
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 2.5;
const ZOOM_STEP = 0.25;
interface Frame {
left: number;
top: number;
width: number;
height: number;
}
function measureEditorPane(): Frame | null {
const pane = document.querySelector(".editor-pane");
if (!pane) return null;
const r = pane.getBoundingClientRect();
return {
left: Math.round(r.left),
top: Math.round(r.top),
width: Math.round(r.width),
height: Math.round(r.height),
};
}
export function ExportPreview() {
const open = useExportPreview((s) => s.open);
const book = useBook((s) => s.book);
if (!open || !book) return null;
return <ExportPreviewModal />;
}
function ExportPreviewModal() {
const book = useBook((s) => s.book)!;
const close = useExportPreview((s) => s.close);
const [pdf, setPdf] = useState<Uint8Array | null>(null);
const [error, setError] = useState<string | null>(null);
const [pages, setPages] = useState(0);
const [zoom, setZoom] = useState(1);
const [saving, setSaving] = useState(false);
const [frame, setFrame] = useState<Frame | null>(measureEditorPane);
const panelRef = useRef<HTMLDivElement>(null);
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
useLayoutEffect(() => {
const update = () => setFrame(measureEditorPane());
update();
const pane = document.querySelector(".editor-pane");
const observer = new ResizeObserver(update);
if (pane) observer.observe(pane);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("resize", update);
};
}, []);
useEffect(() => {
let cancelled = false;
setPdf(null);
setError(null);
const { source, images, fonts } = bookToPdfInputs(book);
compilePdf(source, images, false, fonts)
.then((bytes) => !cancelled && setPdf(bytes))
.catch((e) => !cancelled && setError(String(e)));
return () => {
cancelled = true;
};
}, [book]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [close]);
useEffect(() => {
const el = panelRef.current;
if (!el) return;
const clamp = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
const snap = (z: number) => clamp(Math.round(z / ZOOM_STEP) * ZOOM_STEP);
let start = 1;
let accum = 0;
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
e.preventDefault();
accum += e.deltaY;
if (accum <= -40) {
accum = 0;
setZoom((z) => clamp(z + ZOOM_STEP));
} else if (accum >= 40) {
accum = 0;
setZoom((z) => clamp(z - ZOOM_STEP));
}
};
const onGestureStart = (e: Event) => {
e.preventDefault();
start = zoomRef.current;
};
const onGestureChange = (e: Event) => {
e.preventDefault();
const next = snap(start * (e as unknown as { scale: number }).scale);
setZoom((z) => (z === next ? z : next));
};
const onGestureEnd = (e: Event) => e.preventDefault();
el.addEventListener("wheel", onWheel, { passive: false });
el.addEventListener("gesturestart", onGestureStart);
el.addEventListener("gesturechange", onGestureChange);
el.addEventListener("gestureend", onGestureEnd);
return () => {
el.removeEventListener("wheel", onWheel);
el.removeEventListener("gesturestart", onGestureStart);
el.removeEventListener("gesturechange", onGestureChange);
el.removeEventListener("gestureend", onGestureEnd);
};
}, []);
const scripts = unsupportedScripts(book);
const save = async () => {
if (!pdf) return;
setSaving(true);
try {
await saveBytes(pdf, `${book.metadata.title || "Untitled"}.pdf`, PDF_FILTER);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
const adjustZoom = (delta: number) =>
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round((z + delta) * 100) / 100)));
const compact = (frame?.width ?? 1000) < 460;
return (
<div className="overlay preview-overlay" onClick={close}>
<div
ref={panelRef}
className="panel preview-panel"
style={frame ? { position: "fixed", left: frame.left, top: frame.top, width: frame.width, height: frame.height } : undefined}
onClick={(e) => e.stopPropagation()}
>
<header className="preview-bar">
<button className="icon-btn" onClick={close} title="Close (Esc)">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
<div className="preview-title">{book.metadata.title || "Untitled"}</div>
{pdf && !compact && (
<span className="preview-count">
{pages} {pages === 1 ? "page" : "pages"}
</span>
)}
<div className="preview-zoom">
<button className="icon-btn" disabled={!pdf || zoom <= ZOOM_MIN} onClick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out">
<Icon d="M5 12h14" />
</button>
{!compact && <span>{Math.round(zoom * 100)}%</span>}
<button className="icon-btn" disabled={!pdf || zoom >= ZOOM_MAX} onClick={() => adjustZoom(ZOOM_STEP)} title="Zoom in">
<Icon d="M12 5v14M5 12h14" />
</button>
</div>
<button className="btn-primary" disabled={!pdf || saving} onClick={save}>
{saving ? "Saving…" : compact ? "Save" : "Save PDF…"}
</button>
</header>
{pdf && scripts.length > 0 && (
<div className="preview-warn">
Some {scripts.join(", ")} characters may not render — the PDF fonts cover Latin scripts only.
</div>
)}
{error ? (
<div className="preview-stage">
<pre className="dock-error">{error}</pre>
</div>
) : pdf ? (
<BookPages data={pdf} zoom={zoom} onPages={setPages} />
) : (
<div className="preview-stage">
<div className="preview-loading">
<div className="spinner spinner-dark" />
<p>Typesetting your book…</p>
</div>
</div>
)}
</div>
</div>
);
}
function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; onPages: (n: number) => void }) {
const stageRef = useRef<HTMLDivElement>(null);
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
const [baseRatio, setBaseRatio] = useState<number | null>(null);
const [stageWidth, setStageWidth] = useState(0);
useEffect(() => {
let cancelled = false;
const task = pdfjs.getDocument({ data: data.slice() });
task.promise
.then(async (d) => {
if (cancelled) return;
const first = (await d.getPage(1)).getViewport({ scale: 1 });
if (cancelled) return;
setDoc(d);
setBaseRatio(first.height / first.width);
onPages(d.numPages);
})
.catch(() => {});
return () => {
cancelled = true;
task.destroy();
};
}, [data, onPages]);
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const update = () => setStageWidth(stage.clientWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(stage);
return () => observer.disconnect();
}, [doc]);
const fit = Math.max(280, stageWidth - 56);
const displayWidth = Math.round(fit * zoom);
return (
<div className="preview-stage" ref={stageRef}>
{doc && baseRatio !== null && stageWidth > 0 && (
<div className="preview-col">
{Array.from({ length: doc.numPages }, (_, i) => (
<PdfPage
key={i + 1}
doc={doc}
pageNumber={i + 1}
root={stageRef.current}
displayWidth={displayWidth}
baseRatio={baseRatio}
/>
))}
</div>
)}
</div>
);
}
function PdfPage({
doc,
pageNumber,
root,
displayWidth,
baseRatio,
}: {
doc: PDFDocumentProxy;
pageNumber: number;
root: HTMLElement | null;
displayWidth: number;
baseRatio: number;
}) {
const holder = useRef<HTMLDivElement>(null);
const [show, setShow] = useState(false);
const [ratio, setRatio] = useState(baseRatio);
useEffect(() => {
const el = holder.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setShow(entry.isIntersecting), {
root,
rootMargin: "1400px 0px",
});
observer.observe(el);
return () => observer.disconnect();
}, [root]);
useEffect(() => {
const el = holder.current;
if (!el) return;
if (!show) {
el.replaceChildren();
return;
}
let cancelled = false;
let task: RenderTask | undefined;
(async () => {
try {
const page = await doc.getPage(pageNumber);
if (cancelled) return;
const natural = page.getViewport({ scale: 1 });
setRatio(natural.height / natural.width);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const viewport = page.getViewport({ scale: (displayWidth / natural.width) * dpr });
const canvas = document.createElement("canvas");
canvas.className = "preview-canvas";
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
task = page.render({ canvas, viewport });
await task.promise;
if (cancelled) return;
el.replaceChildren(canvas);
} catch {
// render cancelled or page failed; keep the previous canvas
}
})();
return () => {
cancelled = true;
task?.cancel();
};
}, [show, doc, pageNumber, displayWidth]);
return (
<div
ref={holder}
className="preview-page"
style={{ width: displayWidth, height: Math.round(displayWidth * ratio) }}
/>
);
}
+46 -7
View File
@@ -1,18 +1,26 @@
import { useEffect, useState } from "react";
import { type BookSummary, deleteBook, exampleBook, listBooks, loadBook, newBook, saveBook } from "../library";
import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library";
import type { Book } from "../model/book";
import { importEpub } from "../import/epub";
import { clearPositions } from "../editor/positions";
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()
@@ -22,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();
@@ -38,6 +54,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
const removeBook = async () => {
if (!pendingDelete) return;
await deleteBook(pendingDelete.id);
clearPositions(pendingDelete.id);
setPendingDelete(null);
refresh();
};
@@ -62,9 +79,11 @@ 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={() => onOpen(newBook())}>
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
<Icon d="M12 5v14M5 12h14" size={20} />
<span>New book</span>
</button>
@@ -78,14 +97,34 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<span className="card-badge">Example</span>
</button>
)}
{books.map((b) => (
<div key={b.id} className="card card-book" onClick={() => loadBook(b.id).then(onOpen)}>
{books.map((b) =>
b.corrupt ? (
<div key={b.id} className="card card-book card-corrupt">
<span className="card-title">{b.title}</span>
<span className="card-author">Couldn't be read; a .bak backup may sit beside it.</span>
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div>
) : (
<div
key={b.id}
className="card card-book"
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))}
>
<span className="card-title">{b.title || "Untitled"}</span>
{b.author && <span className="card-author">{b.author}</span>}
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div>
))}
),
)}
</div>
{loaded && books.length === 0 && isDesktop && (
<div className="library-restore">
<button className="restore-link" onClick={restoreFromDrive} disabled={restoreWorking}>
<Icon d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" size={15} />
{restoreWorking ? "Restoring…" : "Restore from Google Drive"}
</button>
</div>
)}
{notice && (
<div className="toast" onClick={() => setNotice(null)}>
{notice}
+3 -1
View File
@@ -12,10 +12,11 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
const container = ref.current;
if (!container) return;
let cancelled = false;
const loadingTask = pdfjs.getDocument({ data: data.slice() });
(async () => {
try {
const doc = await pdfjs.getDocument({ data: data.slice() }).promise;
const doc = await loadingTask.promise;
if (cancelled) return;
const width = container.clientWidth;
const dpr = window.devicePixelRatio || 1;
@@ -47,6 +48,7 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
return () => {
cancelled = true;
loadingTask.destroy();
};
}, [data]);
+42 -1
View File
@@ -6,11 +6,20 @@ interface RowMenuProps {
label: string;
onDuplicate?: () => void;
onDelete: () => void;
onToggleTitle?: () => void;
titleHidden?: boolean;
onToggleMargin?: () => void;
marginHidden?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMenuProps) {
export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidden, onToggleMargin, marginHidden, onOpenChange, className = "" }: RowMenuProps) {
const [open, setOpen] = useState(false);
useEffect(() => {
onOpenChange?.(open);
}, [open]);
const [coords, setCoords] = useState({ top: 0, right: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
@@ -41,6 +50,18 @@ export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMen
};
}, [open]);
const toggleTitle = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onToggleTitle?.();
};
const toggleMargin = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onToggleMargin?.();
};
const duplicate = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
@@ -68,6 +89,26 @@ export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMen
{open &&
createPortal(
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
{onToggleTitle && (
<button className="row-menu-item" onClick={toggleTitle}>
{titleHidden ? (
<Icon d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" size={14} />
) : (
<Icon d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24M1 1l22 22" size={14} />
)}
{titleHidden ? "Show title" : "Hide title"}
</button>
)}
{onToggleMargin && (
<button className="row-menu-item" onClick={toggleMargin}>
{marginHidden ? (
<Icon d="M4 5h16 M12 10v10 M8 16l4 4 4-4" size={14} />
) : (
<Icon d="M4 5h16 M12 20V10 M8 14l4-4 4 4" size={14} />
)}
{marginHidden ? "Show top margin" : "Hide top margin"}
</button>
)}
{onDuplicate && (
<button className="row-menu-item" onClick={duplicate}>
<Icon d="M9 9h11v11h-11z M6 15V5h9" size={14} />
+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)
+50 -11
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { COVER_ID, useBook } from "../store/useBook";
import { type Chapter, type ChapterKind, bodyNumber, chapterKind } from "../model/book";
import { type Chapter, type ChapterKind, bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { AddPageMenu } from "./AddPageMenu";
import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon";
@@ -10,6 +10,8 @@ interface Row {
chapter: Chapter;
index: number;
num: number | null;
part: number | null;
indent: boolean;
}
interface DropTarget {
@@ -37,13 +39,16 @@ function formatEdited(ts: number, now: number): string {
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
export function Sidebar() {
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const chapters = useBook((s) => s.book?.chapters ?? []);
const activeChapterId = useBook((s) => s.activeChapterId);
const setActiveChapter = useBook((s) => s.setActiveChapter);
const addChapter = useBook((s) => s.addChapter);
const addPage = useBook((s) => s.addPage);
const addPart = useBook((s) => s.addPart);
const duplicateChapter = useBook((s) => s.duplicateChapter);
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
const setChapterNoMargin = useBook((s) => s.setChapterNoMargin);
const deleteChapter = useBook((s) => s.deleteChapter);
const moveChapter = useBook((s) => s.moveChapter);
const closeBook = useBook((s) => s.closeBook);
@@ -51,6 +56,7 @@ export function Sidebar() {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
const dropRef = useRef<DropTarget | null>(null);
@@ -62,15 +68,23 @@ export function Sidebar() {
return () => clearInterval(timer);
}, []);
const rows: Row[] = chapters.map((chapter, index) => ({ chapter, index, num: bodyNumber(chapters, index) }));
const rows: Row[] = chapters.map((chapter, index) => ({
chapter,
index,
num: bodyNumber(chapters, index),
part: partNumber(chapters, index),
indent: chapterKind(chapter) === "body" && chapters.slice(0, index).some((c) => chapterKind(c) === "part"),
}));
const inBody = (r: Row) => chapterKind(r.chapter) === "body" || chapterKind(r.chapter) === "part";
const groups: { kind: ChapterKind; label: string; rows: Row[] }[] = [
{ kind: "front", label: "Front matter", rows: rows.filter((r) => chapterKind(r.chapter) === "front") },
{ kind: "body", label: "Chapters", rows: rows.filter((r) => chapterKind(r.chapter) === "body") },
{ kind: "body", label: "Chapters", rows: rows.filter(inBody) },
{ kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") },
];
const groupEnd: Record<ChapterKind, number> = {
front: groups[0].rows.length,
body: groups[0].rows.length + groups[1].rows.length,
part: groups[0].rows.length + groups[1].rows.length,
back: rows.length,
};
@@ -125,7 +139,7 @@ export function Sidebar() {
};
const onRowPointerDown = (e: React.PointerEvent, index: number) => {
if (e.button !== 0 || (e.target as HTMLElement).closest(".row-menu-btn")) return;
if (e.button !== 0 || chapters[index]?.id === menuOpenId || (e.target as HTMLElement).closest(".row-menu-btn")) return;
suppressClick.current = false;
gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false };
window.addEventListener("pointermove", onPointerMove);
@@ -138,6 +152,7 @@ export function Sidebar() {
return;
}
setActiveChapter(id);
onNavigate?.();
};
return (
@@ -149,12 +164,15 @@ export function Sidebar() {
<button
className="cover-item"
data-active={activeChapterId === COVER_ID}
onClick={() => setActiveChapter(COVER_ID)}
onClick={() => {
setActiveChapter(COVER_ID);
onNavigate?.();
}}
>
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
<span className="title">Cover</span>
</button>
<AddPageMenu onAdd={addPage} />
<AddPageMenu onAdd={addPage} onAddPart={addPart} />
<div className="nav-scroll">
{groups.map((group) =>
@@ -162,12 +180,22 @@ export function Sidebar() {
<div key={group.kind} className="nav-section" data-kind={group.kind}>
<div className="nav-label">{group.label}</div>
<ul className="chapters">
{group.rows.map((row, i) => (
{group.rows.map((row, i) => {
const isPart = chapterKind(row.chapter) === "part";
const partLabel = `Part ${partRoman(row.part ?? 0)}`;
const rowLabel = isPart
? row.chapter.title
? `${partLabel}: ${row.chapter.title}`
: partLabel
: row.chapter.title || "Untitled";
return (
<li
key={row.chapter.id}
className="chapter"
data-idx={row.index}
data-kind={group.kind}
data-part={isPart}
data-indent={row.indent}
data-active={row.chapter.id === activeChapterId}
data-dragging={dragIndex === row.index}
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
@@ -178,8 +206,10 @@ export function Sidebar() {
<span className="grip" title="Drag to reorder">
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
</span>
<span className="num">{row.num ?? ""}</span>
{!isPart && <span className="num">{row.num ?? ""}</span>}
<span className="text">
{isPart && <span className="part-eyebrow">Part {partRoman(row.part ?? 0)}</span>}
{(!isPart || row.chapter.title) && (
<span
className="title"
onMouseEnter={(e) => {
@@ -190,17 +220,26 @@ export function Sidebar() {
>
{row.chapter.title || "Untitled"}
</span>
)}
{row.chapter.id === activeChapterId && (
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
)}
</span>
<RowMenu
label="Page options"
onOpenChange={(open) =>
setMenuOpenId((cur) => (open ? row.chapter.id : cur === row.chapter.id ? null : cur))
}
titleHidden={!!row.chapter.noTitle}
onToggleTitle={() => setChapterNoTitle(row.chapter.id, !row.chapter.noTitle)}
marginHidden={!!row.chapter.noMargin}
onToggleMargin={() => setChapterNoMargin(row.chapter.id, !row.chapter.noMargin)}
onDuplicate={() => duplicateChapter(row.chapter.id)}
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })}
onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })}
/>
</li>
))}
);
})}
{!group.rows.length && (
<li className="chapter-drop" data-on={dropTarget?.kind === group.kind}>
Drop here
+19 -19
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions";
import { loadPosition, savePosition } from "./positions";
import { loadPosition, savePosition, type ChapterPosition } from "./positions";
interface EditorProps {
bookId: string;
@@ -10,24 +10,30 @@ interface EditorProps {
content: JSONContent;
onChange: (content: JSONContent) => void;
onReady: (editor: TiptapEditor | null) => void;
onContentError: (error: Error) => void;
}
export function Editor({ bookId, chapterId, content, onChange, onReady }: EditorProps) {
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onContentErrorRef = useRef(onContentError);
onContentErrorRef.current = onContentError;
const openChapter = useRef(chapterId);
const latest = useRef<ChapterPosition | null>(null);
const editor = useEditor({
extensions: editorExtensions,
content,
immediatelyRender: false,
enableContentCheck: true,
editorProps: { attributes: { class: "prose" } },
onContentError: ({ error }) => onContentErrorRef.current(error),
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
});
useEffect(() => {
onReady(editor);
return () => onReady(null);
}, [editor, onReady]);
useEffect(() => {
@@ -35,15 +41,6 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor
let active = true;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
const previous = openChapter.current;
if (previous !== chapterId) {
const { from, to } = editor.state.selection;
savePosition(bookId, previous, { from, to, scroll: scroller?.scrollTop ?? 0 });
}
openChapter.current = chapterId;
editor.commands.setContent(content, { emitUpdate: false });
const saved = loadPosition(bookId, chapterId);
const size = editor.state.doc.content.size;
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
@@ -63,18 +60,20 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chapterId, editor, bookId]);
}, [editor]);
useEffect(() => {
if (!editor) return;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
let timer: ReturnType<typeof setTimeout>;
const persist = () => {
clearTimeout(timer);
timer = setTimeout(() => {
const capture = () => {
const { from, to } = editor.state.selection;
savePosition(bookId, openChapter.current, { from, to, scroll: scroller?.scrollTop ?? 0 });
}, 400);
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 };
};
const persist = () => {
capture();
clearTimeout(timer);
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400);
};
editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true });
@@ -82,8 +81,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor
clearTimeout(timer);
editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", persist);
if (latest.current) savePosition(bookId, chapterId, latest.current);
};
}, [editor, bookId]);
}, [editor, bookId, chapterId]);
return <EditorContent editor={editor} className="editor-host" />;
}
+37
View File
@@ -1,6 +1,13 @@
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
import type { Editor } from "@tiptap/react";
import { Icon } from "../components/Icon";
import type { Alignment } from "./align";
const ALIGN_ICONS: Record<Alignment, string> = {
left: "M4 7h16M4 12h10M4 17h13",
center: "M4 7h16M7 12h10M5 17h14",
right: "M4 7h16M10 12h10M7 17h13",
};
function readImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
@@ -23,6 +30,7 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
const linkInputRef = useRef<HTMLInputElement>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [linkValue, setLinkValue] = useState("");
const [alignOpen, setAlignOpen] = useState(false);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() => {
@@ -67,6 +75,13 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
setLinkOpen(false);
};
const align: Alignment = (editor.getAttributes("heading").align ?? editor.getAttributes("paragraph").align) || "left";
const setAlign = (value: Alignment) => {
editor.chain().focus().setTextAlign(value).run();
setAlignOpen(false);
};
const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => (
<button
className="tool"
@@ -87,6 +102,28 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
{tool(editor.isActive("heading", { level: 2 }), () => editor.chain().focus().toggleHeading({ level: 2 }).run(), "Heading", <Icon d="M5 5v14M5 12h8M13 5v14" />)}
{tool(editor.isActive("blockquote"), () => editor.chain().focus().toggleBlockquote().run(), "Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />)}
{tool(editor.isActive("bulletList"), () => editor.chain().focus().toggleBulletList().run(), "Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01" />)}
<span className="tool-wrap">
{tool(alignOpen || align !== "left", () => setAlignOpen((v) => !v), "Align", <Icon d={ALIGN_ICONS[align]} />)}
{alignOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setAlignOpen(false)} />
<div className="align-pop" onMouseDown={(e) => e.stopPropagation()}>
{(["left", "center", "right"] as Alignment[]).map((value) => (
<button
key={value}
className="tool"
data-on={align === value}
title={`Align ${value}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setAlign(value)}
>
<Icon d={ALIGN_ICONS[value]} />
</button>
))}
</div>
</>
)}
</span>
<span className="tool-sep" />
<span className="tool-wrap">
{tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />)}
+64
View File
@@ -0,0 +1,64 @@
import { Extension } from "@tiptap/core";
export type Alignment = "left" | "center" | "right";
const ALIGNED_TYPES = ["paragraph", "heading"];
declare module "@tiptap/core" {
interface Commands<ReturnType> {
textAlign: {
setTextAlign: (align: Alignment) => ReturnType;
};
}
}
export const TextAlign = Extension.create({
name: "textAlign",
addGlobalAttributes() {
return [
{
types: ALIGNED_TYPES,
attributes: {
align: {
default: null,
keepOnSplit: true,
parseHTML: (el) => {
const value = el.style.textAlign || el.getAttribute("data-align");
return value === "center" || value === "right" ? value : null;
},
renderHTML: (attrs) =>
attrs.align ? { "data-align": attrs.align, style: `text-align: ${attrs.align}` } : {},
},
},
},
];
},
addCommands() {
return {
setTextAlign:
(align) =>
({ tr, dispatch }) => {
const value = align === "left" ? null : align;
const { from, to } = tr.selection;
let changed = false;
tr.doc.nodesBetween(from, to, (node, pos) => {
if (ALIGNED_TYPES.includes(node.type.name) && node.attrs.align !== value) {
if (dispatch) tr.setNodeAttribute(pos, "align", value);
changed = true;
}
});
return changed;
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-l": () => this.editor.commands.setTextAlign("left"),
"Mod-Shift-e": () => this.editor.commands.setTextAlign("center"),
"Mod-Shift-r": () => this.editor.commands.setTextAlign("right"),
};
},
});
+4
View File
@@ -3,8 +3,10 @@ import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Figure } from "./figure";
import { ParagraphIndent } from "./indent";
import { TextAlign } from "./align";
import { SearchHighlight } from "./search";
import { Proofing } from "./proofing";
import { Paste } from "./paste";
export const editorExtensions: Extensions = [
StarterKit.configure({
@@ -17,6 +19,8 @@ export const editorExtensions: Extensions = [
}),
Figure,
ParagraphIndent,
TextAlign,
SearchHighlight,
Proofing,
Paste,
];
+76
View File
@@ -0,0 +1,76 @@
import { Extension } from "@tiptap/core";
import type { JSONContent } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
function readImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function imageFiles(data: DataTransfer | null): File[] {
if (!data) return [];
const files = Array.from(data.files).filter((f) => f.type.startsWith("image/"));
if (files.length) return files;
return Array.from(data.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
}
function plainContent(text: string): JSONContent[] {
return text
.replace(/\r\n?/g, "\n")
.split(/\n{2,}/)
.map((block) => {
const content: JSONContent[] = [];
block.split("\n").forEach((line, i) => {
if (i > 0) content.push({ type: "hardBreak" });
if (line) content.push({ type: "text", text: line });
});
return content.length ? { type: "paragraph", content } : { type: "paragraph" };
});
}
export const Paste = Extension.create({
name: "pasteHandler",
addKeyboardShortcuts() {
const editor = this.editor;
return {
"Mod-Shift-v": () => {
navigator.clipboard
.readText()
.then((text) => {
if (text) editor.chain().focus().insertContent(plainContent(text)).run();
})
.catch(() => {});
return true;
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
props: {
handlePaste(_view, event) {
const files = imageFiles(event.clipboardData);
if (!files.length) return false;
event.preventDefault();
files.forEach((file) =>
readImage(file).then((src) =>
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run(),
),
);
return true;
},
},
}),
];
},
});
+38 -1
View File
@@ -16,6 +16,14 @@ function readAll(): Store {
}
}
function write(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
return;
}
}
export function loadPosition(bookId: string, chapterId: string): ChapterPosition | null {
return readAll()[bookId]?.[chapterId] ?? null;
}
@@ -23,5 +31,34 @@ export function loadPosition(bookId: string, chapterId: string): ChapterPosition
export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void {
const all = readAll();
(all[bookId] ||= {})[chapterId] = position;
localStorage.setItem(KEY, JSON.stringify(all));
write(KEY, all);
}
const ACTIVE_KEY = "margin-active-chapter";
function readActive(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem(ACTIVE_KEY) || "{}") as Record<string, string>;
} catch {
return {};
}
}
export function loadActiveChapter(bookId: string): string | null {
return readActive()[bookId] ?? null;
}
export function saveActiveChapter(bookId: string, chapterId: string): void {
const all = readActive();
all[bookId] = chapterId;
write(ACTIVE_KEY, all);
}
export function clearPositions(bookId: string): void {
const positions = readAll();
const active = readActive();
delete positions[bookId];
delete active[bookId];
write(KEY, positions);
write(ACTIVE_KEY, active);
}
+9 -3
View File
@@ -37,10 +37,11 @@ interface ProofingState {
export const proofingKey = new PluginKey<ProofingState>("proofing");
function buildDecorations(doc: PMNode, issues: ProofIssue[]): DecorationSet {
function buildDecorations(doc: PMNode, issues: ProofIssue[], cursor: number | null): DecorationSet {
const size = doc.content.size;
const decos = issues
.filter((i) => i.from < i.to && i.to <= size)
.filter((i) => cursor === null || cursor < i.from || cursor > i.to)
.map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` }));
return DecorationSet.create(doc, decos);
}
@@ -73,9 +74,11 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
state: {
init: () => ({ issues: [], decorations: DecorationSet.empty }),
apply(tr, value, _old, newState) {
const sel = newState.selection;
const cursor = sel.empty ? sel.head : null;
const meta = tr.getMeta(proofingKey) as { issues: ProofIssue[] } | { clear: true } | undefined;
if (meta && "issues" in meta) {
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues) };
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues, cursor) };
}
if (meta && "clear" in meta) {
return { issues: [], decorations: DecorationSet.empty };
@@ -84,7 +87,10 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
const issues = value.issues
.map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) }))
.filter((i) => i.to > i.from);
return { issues, decorations: value.decorations.map(tr.mapping, tr.doc) };
return { issues, decorations: buildDecorations(newState.doc, issues, cursor) };
}
if (tr.selectionSet && value.issues.length) {
return { issues: value.issues, decorations: buildDecorations(newState.doc, value.issues, cursor) };
}
return value;
},
+115 -43
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 { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement, partNumber, partRoman } from "../model/book";
import { fontStack, fontsUsed } from "../model/fonts";
export interface EpubFile {
path: string;
@@ -25,7 +26,7 @@ function slug(text: string): string {
function imageExtension(dataUrl: string): string {
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
const kind = (match?.[1] ?? "png").toLowerCase();
const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
return kind === "jpeg" ? "jpg" : kind;
}
@@ -38,12 +39,65 @@ 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 ?? "");
const marks = node.marks ?? [];
if (marks.some((m) => m.type === "code")) text = `<code>${text}</code>`;
if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`;
if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`;
if (marks.some((m) => m.type === "strike")) text = `<s>${text}</s>`;
if (marks.some((m) => m.type === "underline")) text = `<u>${text}</u>`;
const href = marks.find((m) => m.type === "link")?.attrs?.href;
if (href) text = `<a href="${attr(href)}">${text}</a>`;
return text;
@@ -56,10 +110,10 @@ function inlines(content: JSONContent[] = []): string {
return content.map(inline).join("");
}
function listItem(item: JSONContent): string {
function listItem(item: JSONContent, paths: Map<string, string>): string {
const inner = (item.content ?? [])
.filter((c) => c.type === "paragraph")
.map((p) => inlines(p.content))
.map((child) => block(child, paths))
.filter((s) => s.length > 0)
.join("");
return `<li>${inner}</li>`;
}
@@ -78,23 +132,29 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
return `<figure class="placement-${attr(placement)}"${style}><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
}
function alignStyle(align: unknown): string {
return align === "center" || align === "right" ? ` style="text-align:${align}"` : "";
}
function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) {
case "paragraph": {
const body = inlines(node.content);
if (!body.trim()) return "<p>&#160;</p>";
const align = node.attrs?.align;
if (align === "center" || align === "right") return `<p${alignStyle(align)}>${body}</p>`;
return `<p${node.attrs?.indent ? ' data-indent="true"' : ""}>${body}</p>`;
}
case "heading":
return node.attrs?.level === 3
? `<h3>${inlines(node.content)}</h3>`
: `<h2>${inlines(node.content)}</h2>`;
case "heading": {
const tag = node.attrs?.level === 3 ? "h3" : "h2";
return `<${tag}${alignStyle(node.attrs?.align)}>${inlines(node.content)}</${tag}>`;
}
case "blockquote":
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
case "bulletList":
return `<ul>${(node.content ?? []).map(listItem).join("")}</ul>`;
return `<ul>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ul>`;
case "orderedList":
return `<ol>${(node.content ?? []).map(listItem).join("")}</ol>`;
return `<ol>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ol>`;
case "horizontalRule":
return `<hr class="scene-break"/>`;
case "figure":
@@ -237,6 +297,7 @@ function contentOpf(
chapterIds: string[],
assetPaths: string[],
cover: { href: string; mediaType: string },
fontManifest: string[],
): string {
const meta = book.metadata;
const manifest: string[] = [
@@ -244,8 +305,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(
@@ -284,9 +344,12 @@ function contentOpf(
function navXhtml(book: Book): string {
const items = book.chapters
.map((chapter, i) => {
if (chapter.noTitle && chapterKind(chapter) !== "body") return "";
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled";
const label = chapter.noTitle ? fallback : chapter.title || fallback;
const kind = chapterKind(chapter);
if (chapter.noTitle && kind !== "body" && kind !== "part") return "";
const partLabel = `Part ${partRoman(partNumber(book.chapters, i) ?? 0)}`;
const fallback = kind === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : kind === "part" ? partLabel : "Untitled";
const base = chapter.noTitle ? fallback : chapter.title || fallback;
const label = kind === "part" && !chapter.noTitle && chapter.title ? `${partLabel}: ${chapter.title}` : base;
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(label)}</a></li>`;
})
.filter(Boolean)
@@ -312,16 +375,20 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
const chapter = book.chapters[index];
const kind = chapterKind(chapter);
const num = bodyNumber(book.chapters, index);
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
const eyebrow = kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : "";
const heading = chapter.noTitle ? "" : `<h1>${esc(title)}</h1>`;
const partLabel = `Part ${partRoman(partNumber(book.chapters, index) ?? 0)}`;
const eyebrow =
kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : kind === "part" ? `<p class="eyebrow">${esc(partLabel)}</p>` : "";
const headingText = kind === "part" ? chapter.title : chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
const heading = chapter.noTitle || (kind === "part" && !chapter.title) ? "" : `<h1>${esc(headingText)}</h1>`;
const docTitle = chapter.title || (kind === "part" ? partLabel : kind === "body" ? `Chapter ${num}` : "Untitled");
const epubType = kind === "body" ? "chapter" : kind === "part" ? "part" : kind === "front" ? "frontmatter" : "backmatter";
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
<head>
<title>${esc(title)}</title>
<title>${esc(docTitle)}</title>
<link rel="stylesheet" type="text/css" href="style.css"/>
</head>
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}">
<body epub:type="${epubType}">
<header class="chapter-opener">
${eyebrow}
${heading}
@@ -331,23 +398,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 {
@@ -380,6 +433,7 @@ p[data-indent="true"] {
}
h1, h2, h3 {
font-family: ${fontStack(book.settings.fonts.heading)};
hyphens: none;
line-height: 1.25;
font-weight: 500;
@@ -394,6 +448,18 @@ h2, h3 {
margin: 3em 0 2.2em;
}
body[epub:type="part"] {
page-break-before: always;
}
body[epub:type="part"] .chapter-opener {
margin-top: 32vh;
}
body[epub:type="part"] .eyebrow {
font-size: 0.82em;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.28em;
@@ -406,6 +472,11 @@ h2, h3 {
margin: 0;
}
code {
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
}
blockquote {
margin: 1.2em 1.6em;
font-style: italic;
@@ -469,7 +540,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);
@@ -480,12 +551,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,
];
@@ -497,12 +568,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);
const { source, images, fonts } = bookToPdfInputs(book);
const bytes = await compilePdf(source, images, true, fonts);
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
}
+6
View File
@@ -1,5 +1,6 @@
import type { Book } from "../model/book";
import { exportEpub, exportPdf } from "./exporters";
import { unsupportedScripts } from "./typst";
import { useBook } from "../store/useBook";
import { isDesktop } from "../ipc";
@@ -29,6 +30,11 @@ export async function runExport(format: "pdf" | "epub"): Promise<void> {
await nextPaint();
try {
await run(book);
if (format === "pdf") {
const scripts = unsupportedScripts(book);
if (scripts.length)
setNotice(`Some ${scripts.join(", ")} characters may not render — the PDF fonts cover Latin scripts only.`);
}
} catch (e) {
setNotice(`${label} export failed: ${e}`);
} finally {
+131 -42
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 { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement, partNumber, partRoman } 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" },
@@ -10,27 +21,41 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
};
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g;
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
function esc(text: string): string {
return text.replace(INLINE_SPECIAL, (m) => "\\" + m);
return text.replace(LINE_SEPARATORS, " ").replace(INLINE_SPECIAL, (m) => "\\" + m);
}
function guardLineStart(line: string): string {
if (/^[=\-+/]/.test(line)) return "\\" + line;
if (/^\d+\./.test(line)) return line.replace(".", "\\.");
return line;
function guardLineStart(text: string): string {
if (/^[=\-+/]/.test(text)) return "\\" + text;
if (/^\d+\./.test(text)) return text.replace(".", "\\.");
return text;
}
function str(value: string): string {
return JSON.stringify(value);
}
function inline(node: JSONContent): string {
function localeParams(tag: string): string {
const parts = (tag || "en").trim().split(/[-_]/);
const primary = parts[0] ?? "";
const lang = /^[a-zA-Z]{2,3}$/.test(primary) ? primary.toLowerCase() : "en";
const region = parts.slice(1).find((p) => /^[a-zA-Z]{2}$/.test(p));
const params = `lang: ${str(lang)}`;
return region ? `${params}, region: ${str(region.toUpperCase())}` : params;
}
function inline(node: JSONContent, atLineStart: boolean): string {
if (node.type === "text") {
let text = esc(node.text ?? "");
const marks = node.marks ?? [];
if (marks.some((m) => m.type === "code")) return `#raw(${str(node.text ?? "")})`;
let text = esc(node.text ?? "");
if (atLineStart) text = guardLineStart(text);
if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`;
if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`;
if (marks.some((m) => m.type === "strike")) text = `#strike[${text}]`;
if (marks.some((m) => m.type === "underline")) text = `#underline[${text}]`;
const href = marks.find((m) => m.type === "link")?.attrs?.href;
if (href) text = `#link(${str(href)})[${text}]`;
return text;
@@ -39,15 +64,15 @@ function inline(node: JSONContent): string {
return "";
}
function inlines(content: JSONContent[] = []): string {
return content.map(inline).join("");
function inlines(content: JSONContent[] = [], atLineStart = false): string {
return content.map((node, i) => inline(node, atLineStart && i === 0)).join("");
}
function listItem(item: JSONContent): string {
return (item.content ?? [])
.filter((c) => c.type === "paragraph")
.map((p) => inlines(p.content))
.join(" ");
function listItem(item: JSONContent, paths: Map<string, string>): string {
const parts = (item.content ?? [])
.map((child) => block(child, paths))
.filter((s) => s.length > 0);
return `[${parts.join("\n\n")}]`;
}
function figure(node: JSONContent, paths: Map<string, string>): string {
@@ -59,26 +84,33 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
}
const placed = placement as FigurePlacement;
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
const caption = node.attrs?.caption ? `, caption: [${esc(node.attrs.caption)}]` : "";
const caption = node.attrs?.caption ? `, caption: [${guardLineStart(esc(node.attrs.caption))}]` : "";
const float = placement === "float-top" ? ", placement: top" : "";
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
}
function aligned(align: unknown, body: string): string {
if (align === "center" || align === "right") return `#align(${align})[#set par(justify: false)\n${body}]`;
return body;
}
function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) {
case "paragraph": {
const body = guardLineStart(inlines(node.content));
const body = inlines(node.content, true);
if (!body.trim()) return "~";
const align = node.attrs?.align;
if (align === "center" || align === "right") return aligned(align, body);
return node.attrs?.indent ? `#h(1.3em)${body}` : body;
}
case "heading":
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content)}]`;
return aligned(node.attrs?.align, `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`);
case "blockquote":
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
case "bulletList":
return (node.content ?? []).map((li) => `- ${listItem(li)}`).join("\n");
return `#list(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
case "orderedList":
return (node.content ?? []).map((li) => `+ ${listItem(li)}`).join("\n");
return `#enum(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
case "horizontalRule":
return "#scenebreak";
case "figure":
@@ -99,6 +131,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},
@@ -106,9 +140,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, ${localeParams(meta.language)}, 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)
@@ -120,10 +154,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)
@@ -135,7 +169,7 @@ function preamble(book: Book): string {
#let openchapter(info) = {
pagebreak(weak: true)
[#metadata(info) <chap>]
v(2.1in)
if not info.nomargin { v(2.1in) }
align(center)[
#if info.kind == "body" {
text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
@@ -148,6 +182,20 @@ function preamble(book: Book): string {
v(1.5em)
}
#let openpart(info) = {
pagebreak(to: "odd", weak: true)
[#metadata(info) <chap>]
if not info.nomargin { v(3.1in) }
align(center)[
#text(font: "Hanken Grotesk", size: 10pt, weight: "semibold", tracking: 0.34em)[#upper("Part " + info.numeral)]
#if not info.notitle and info.title != "" {
v(0.85em)
heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
}
]
v(1.5em)
}
#let pageof(loc) = {
let pat = loc.page-numbering()
if pat == none { pat = "1" }
@@ -159,7 +207,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)
@@ -168,6 +216,7 @@ function preamble(book: Book): string {
let info = it.value
let pg = pageof(it.location())
let body = info.kind == "body"
let part = info.kind == "part"
let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em }
prev = info.kind
block(width: 100%, above: gap, below: 0.95em, {
@@ -177,7 +226,9 @@ function preamble(book: Book): string {
column-gutter: (0.65em, 1em),
align: (right + top, left + top, right + top),
if body { info.num } else { [] },
if body { info.title } else { emph(info.title) },
if part {
strong(if info.title != "" { "Part " + info.numeral + ": " + info.title } else { "Part " + info.numeral })
} else if body { info.title } else { emph(info.title) },
pg,
)]
})
@@ -188,20 +239,22 @@ function preamble(book: Book): string {
function imageExtension(dataUrl: string): string {
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
const kind = (match?.[1] ?? "png").toLowerCase();
const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
return kind === "jpeg" ? "jpg" : kind;
}
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)`
@@ -211,7 +264,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}
@@ -251,6 +304,34 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map<st
return collectImages(book.chapters.map((chapter) => chapter.content));
}
const SCRIPT_RANGES: { label: string; test: RegExp }[] = [
{ label: "CJK", test: /[぀-鿿가-힯豈-﫿]/u },
{ label: "Arabic", test: /[؀-ۿݐ-ݿ]/u },
{ label: "Hebrew", test: /[֐-׿]/u },
{ label: "Devanagari", test: /[ऀ-ॿ]/u },
{ label: "Thai", test: /[฀-๿]/u },
{ label: "emoji", test: /[☀-➿]|[\u{1f000}-\u{1faff}]/u },
];
function collectText(book: Book): string {
const parts = [book.metadata.title, book.metadata.subtitle, book.metadata.author];
const visit = (node: JSONContent) => {
if (node.type === "text" && node.text) parts.push(node.text);
if (node.attrs?.caption) parts.push(String(node.attrs.caption));
(node.content ?? []).forEach(visit);
};
book.chapters.forEach((chapter) => {
parts.push(chapter.title);
visit(chapter.content);
});
return parts.join("\n");
}
export function unsupportedScripts(book: Book): string[] {
const text = collectText(book);
return SCRIPT_RANGES.filter((s) => s.test.test(text)).map((s) => s.label);
}
function cleanTitle(title: string): string {
return (title || "")
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
@@ -264,18 +345,26 @@ function normTitle(title: string): string {
function inToc(book: Book, index: number): boolean {
const chapter = book.chapters[index];
if (chapter.noTitle && chapterKind(chapter) !== "body") return false;
const kind = chapterKind(chapter);
if (kind === "part") return true;
if (chapter.noTitle && kind !== "body") return false;
const title = cleanTitle(chapter.title) || "Untitled";
return !(chapterKind(chapter) === "front" && normTitle(title) === normTitle(book.metadata.title));
return !(kind === "front" && normTitle(title) === normTitle(book.metadata.title));
}
function openerCall(book: Book, index: number): string {
const chapter = book.chapters[index];
const kind = chapterKind(chapter);
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
const notitle = !!chapter.noTitle;
const nomargin = !!chapter.noMargin;
if (kind === "part") {
const numeral = partRoman(partNumber(book.chapters, index) ?? 0);
const title = notitle ? "" : cleanTitle(chapter.title);
return `#openpart((kind: "part", numeral: ${str(numeral)}, title: ${str(title)}, notitle: ${notitle}, nomargin: ${nomargin}, toc: ${inToc(book, index)}))`;
}
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled";
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, nomargin: ${nomargin}, toc: ${inToc(book, index)}))`;
}
export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
@@ -287,13 +376,13 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
#set page(numbering: "i")
#counter(page).update(1)`;
const firstBody = book.chapters.findIndex((c) => chapterKind(c) === "body");
const firstMain = book.chapters.findIndex((c) => chapterKind(c) === "body" || chapterKind(c) === "part");
const firstToc = book.chapters.findIndex((_, i) => inToc(book, i));
const body = book.chapters
.map((chapter, i) => {
const toc = i === firstToc ? `#contents()\n\n` : "";
const reset = i === firstBody ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : "";
const reset = i === firstMain ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : "";
return `${toc}${reset}${openerCall(book, i)}\n\n${chapterBody(chapter.content, paths)}`;
})
.join("\n\n");
@@ -301,16 +390,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 {
@@ -325,8 +414,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) };
}
+124 -22
View File
@@ -1,7 +1,8 @@
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, createCover } from "../model/book";
import { type Book, type BookMetadata, type Chapter, type Cover, clampLanguage, createCover } from "../model/book";
import { DEFAULT_FONTS } from "../model/fonts";
export interface RawFile {
path: string;
@@ -9,7 +10,7 @@ export interface RawFile {
encoding: "utf8" | "base64";
}
type Mark = { type: string };
type Mark = { type: string; attrs?: Record<string, unknown> };
const BLOCK_TAGS = new Set([
"p", "div", "section", "article", "main", "header", "footer", "aside",
@@ -81,8 +82,8 @@ function hasMark(marks: Mark[], type: string): boolean {
return marks.some((m) => m.type === type);
}
function addMark(marks: Mark[], type: string): Mark[] {
return hasMark(marks, type) ? marks : [...marks, { type }];
function addMark(marks: Mark[], type: string, attrs?: Record<string, unknown>): Mark[] {
return hasMark(marks, type) ? marks : [...marks, attrs ? { type, attrs } : { type }];
}
function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
@@ -102,6 +103,15 @@ function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
collectInline(el, addMark(marks, "bold"), out);
} else if (tag === "em" || tag === "i") {
collectInline(el, addMark(marks, "italic"), out);
} else if (tag === "u") {
collectInline(el, addMark(marks, "underline"), out);
} else if (tag === "s" || tag === "strike" || tag === "del") {
collectInline(el, addMark(marks, "strike"), out);
} else if (tag === "code") {
collectInline(el, addMark(marks, "code"), out);
} else if (tag === "a") {
const href = el.getAttribute("href");
collectInline(el, href ? addMark(marks, "link", { href }) : marks, out);
} else if (!BLOCK_TAGS.has(tag)) {
collectInline(el, marks, out);
}
@@ -137,7 +147,7 @@ function meaningful(inline: JSONContent[]): boolean {
function imgFigure(img: Element, caption = ""): JSONContent[] {
const src = img.getAttribute("src");
if (!src || !src.startsWith("data:")) return [];
if (src && src.startsWith("data:")) {
return [
{
type: "figure",
@@ -145,6 +155,16 @@ function imgFigure(img: Element, caption = ""): JSONContent[] {
},
];
}
const alt = (img.getAttribute("alt") ?? "").trim();
return alt ? [{ type: "paragraph", content: [{ type: "text", text: alt }] }] : [];
}
function svgFigure(el: Element): JSONContent[] {
const markup = el.outerHTML;
if (!markup) return [];
const src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(markup)))}`;
return [{ type: "figure", attrs: { src, alt: "", caption: "", placement: "full-width" } }];
}
function figureFrom(el: Element): JSONContent[] {
const img = el.querySelector("img");
@@ -165,18 +185,35 @@ function hasBlockChild(el: Element): boolean {
return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase()));
}
function listItems(el: Element): JSONContent[] {
function listItems(el: Element, depth: number): JSONContent[] {
const items: JSONContent[] = [];
Array.from(el.children)
.filter((c) => c.localName.toLowerCase() === "li")
.forEach((li) => {
const content = blocksFrom(li);
const content = blocksFrom(li, depth + 1);
items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] });
});
return items;
}
function blockFromElement(el: Element): JSONContent[] {
function tableBlocks(el: Element): JSONContent[] {
const out: JSONContent[] = [];
Array.from(el.querySelectorAll("tr")).forEach((tr) => {
const content: JSONContent[] = [];
Array.from(tr.children)
.filter((c) => c.localName.toLowerCase() === "td" || c.localName.toLowerCase() === "th")
.forEach((cell) => {
const inline = inlineOf(cell);
if (!meaningful(inline)) return;
if (content.length) content.push({ type: "text", text: " · " });
content.push(...inline);
});
if (meaningful(content)) out.push({ type: "paragraph", content });
});
return out;
}
function blockFromElement(el: Element, depth: number): JSONContent[] {
const tag = el.localName.toLowerCase();
switch (tag) {
case "p":
@@ -192,15 +229,15 @@ function blockFromElement(el: Element): JSONContent[] {
return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : [];
}
case "blockquote": {
const inner = blocksFrom(el);
const inner = blocksFrom(el, depth + 1);
return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }];
}
case "ul": {
const items = listItems(el);
const items = listItems(el, depth);
return items.length ? [{ type: "bulletList", content: items }] : [];
}
case "ol": {
const items = listItems(el);
const items = listItems(el, depth);
return items.length ? [{ type: "orderedList", content: items }] : [];
}
case "hr":
@@ -209,22 +246,27 @@ function blockFromElement(el: Element): JSONContent[] {
return figureFrom(el);
case "img":
return imgFigure(el);
case "svg":
return svgFigure(el);
case "table":
return tableBlocks(el);
case "script":
case "style":
return [];
default:
return hasBlockChild(el) ? blocksFrom(el) : paragraphAndFigures(el);
return hasBlockChild(el) ? blocksFrom(el, depth + 1) : paragraphAndFigures(el);
}
}
function blocksFrom(parent: Node): JSONContent[] {
function blocksFrom(parent: Node, depth = 0): JSONContent[] {
if (depth > 64) return [];
const out: JSONContent[] = [];
parent.childNodes.forEach((child) => {
if (child.nodeType === 3) {
const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim();
if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] });
} else if (child.nodeType === 1) {
out.push(...blockFromElement(child as Element));
out.push(...blockFromElement(child as Element, depth));
}
});
return out;
@@ -234,6 +276,41 @@ function headingText(node: JSONContent): string {
return (node.content ?? []).map((n) => n.text ?? "").join("").trim();
}
function isbnChecksum(s: string): boolean {
if (s.length === 10) {
let sum = 0;
for (let i = 0; i < 10; i++) {
const v = s[i] === "X" ? 10 : Number(s[i]);
if (Number.isNaN(v) || (s[i] === "X" && i !== 9)) return false;
sum += v * (10 - i);
}
return sum % 11 === 0;
}
if (s.length === 13 && /^\d{13}$/.test(s)) {
let sum = 0;
for (let i = 0; i < 13; i++) sum += Number(s[i]) * (i % 2 === 0 ? 1 : 3);
return sum % 10 === 0;
}
return false;
}
function findIsbn(opf: Document): string {
const ids = Array.from(opf.getElementsByTagName("*")).filter((el) => el.localName === "identifier");
const ranked = ids
.map((el) => ({
text: el.textContent ?? "",
tagged:
/isbn/i.test(el.getAttribute("opf:scheme") || el.getAttribute("scheme") || "") ||
/urn:isbn:/i.test(el.textContent ?? ""),
}))
.sort((a, b) => Number(b.tagged) - Number(a.tagged));
for (const { text } of ranked) {
const cleaned = text.replace(/[^0-9Xx]/g, "").toUpperCase();
if (isbnChecksum(cleaned)) return cleaned;
}
return "";
}
interface ManifestItem {
href: string;
mediaType: string;
@@ -317,6 +394,11 @@ function readNavTitles(
return titles;
}
function fileToDataUri(file: RawFile, mime: string): string {
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
return `data:${mime};base64,${data}`;
}
function resolveImages(
doc: Document,
baseDir: string,
@@ -335,12 +417,29 @@ function resolveImages(
img.removeAttribute("src");
return;
}
const mime = mimeByPath.get(resolved) || mimeFromExt(resolved);
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
img.setAttribute("src", `data:${mime};base64,${data}`);
img.setAttribute("src", fileToDataUri(file, mimeByPath.get(resolved) || mimeFromExt(resolved)));
});
}
function findCover(
opf: Document,
manifest: Map<string, ManifestItem>,
byPath: Map<string, RawFile>,
): Cover {
let item = Array.from(manifest.values()).find((m) => m.properties.split(/\s+/).includes("cover-image"));
if (!item) {
const metaCover = Array.from(opf.getElementsByTagName("*")).find(
(el) => el.localName === "meta" && el.getAttribute("name") === "cover",
);
const id = metaCover?.getAttribute("content");
if (id) item = manifest.get(id);
}
if (!item) return createCover();
const file = byPath.get(normalize(item.href));
if (!file) return createCover();
return { ...createCover(), kind: "image", image: fileToDataUri(file, item.mediaType || mimeFromExt(item.href)) };
}
function buildChapter(
file: RawFile,
baseDir: string,
@@ -394,19 +493,22 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
spine.forEach((item, i) => {
const file = byPath.get(normalize(item.href));
if (!file) return;
try {
chapters.push(
buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest),
);
} catch {
return;
}
});
if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found.");
const identifier = localText(opf, "identifier");
const metadata: BookMetadata = {
title: localText(opf, "title") || fallbackName,
subtitle: "",
author: localText(opf, "creator"),
isbn: /\d{9,13}/.test(identifier) ? identifier.replace(/[^0-9Xx]/g, "") : "",
language: localText(opf, "language") || "en",
isbn: findIsbn(opf),
language: clampLanguage(localText(opf, "language") || "en"),
};
return {
@@ -414,8 +516,8 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
id: crypto.randomUUID(),
metadata,
theme: "quiet-press",
settings: { trim: "6x9", bleed: true },
cover: createCover(),
settings: { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS },
cover: findCover(opf, manifest, byPath),
chapters,
};
}
+27 -2
View File
@@ -7,7 +7,32 @@ export interface ImageInput {
data: string;
}
export async function compilePdf(source: string, images: ImageInput[] = []): Promise<Uint8Array> {
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images });
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,
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 [];
}
}
+27 -2
View File
@@ -1,11 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import { isDesktop } from "./ipc";
import { type Book, createBook, starterBook } from "./model/book";
import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book";
export interface BookSummary {
id: string;
title: string;
author: string;
corrupt?: boolean;
}
export async function listBooks(): Promise<BookSummary[]> {
@@ -15,7 +16,16 @@ export async function listBooks(): Promise<BookSummary[]> {
export async function loadBook(id: string): Promise<Book> {
const contents = await invoke<string>("load_book", { id });
return JSON.parse(contents) as Book;
let book: Book;
try {
book = JSON.parse(contents) as Book;
} catch {
throw new Error("the book file is corrupt or unreadable");
}
if (schemaVersion(book.schema) > SCHEMA_VERSION) {
throw new Error("this book was made with a newer version of Margin; update the app to open it");
}
return book;
}
export async function saveBook(book: Book): Promise<void> {
@@ -35,3 +45,18 @@ export function newBook(): Book {
export function exampleBook(): Book {
return starterBook();
}
export async function createAndOpenBook(
open: (book: Book) => void,
onError?: (message: string) => void,
): Promise<void> {
const book = newBook();
if (isDesktop) {
try {
await saveBook(book);
} catch (e) {
onError?.(`Could not create book: ${e}`);
}
}
open(book);
}
+60 -4
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";
@@ -62,7 +64,7 @@ export function trimRatio(trim: TrimSize): number {
return w / h;
}
export type ChapterKind = "front" | "body" | "back";
export type ChapterKind = "front" | "body" | "part" | "back";
export interface Chapter {
id: string;
@@ -71,6 +73,7 @@ export interface Chapter {
updatedAt: number;
kind?: ChapterKind;
noTitle?: boolean;
noMargin?: boolean;
}
export interface PageType {
@@ -105,6 +108,31 @@ export function bodyNumber(chapters: Chapter[], index: number): number | null {
return n;
}
export function partNumber(chapters: Chapter[], index: number): number | null {
if (index < 0 || index >= chapters.length || chapterKind(chapters[index]) !== "part") return null;
let n = 0;
for (let i = 0; i <= index; i++) if (chapterKind(chapters[i]) === "part") n++;
return n;
}
const ROMAN: [number, string][] = [
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
[50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
];
export function partRoman(n: number): string {
if (n < 1) return String(n);
let num = n;
let out = "";
for (const [value, sym] of ROMAN) {
while (num >= value) {
out += sym;
num -= value;
}
}
return out;
}
export interface Book {
schema: "margin/1";
id: string;
@@ -115,6 +143,14 @@ export interface Book {
chapters: Chapter[];
}
export const SCHEMA_VERSION = 1;
export function schemaVersion(schema: unknown): number {
if (typeof schema !== "string") return 0;
const match = /^margin\/(\d+)$/.exec(schema);
return match ? Number(match[1]) : 0;
}
function paragraph(text: string): JSONContent {
return { type: "paragraph", content: [{ type: "text", text }] };
}
@@ -131,6 +167,10 @@ export function createPage(group: "front" | "back", title: string): Chapter {
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: group, updatedAt: Date.now() };
}
export function createPart(title = ""): Chapter {
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: "part", updatedAt: Date.now() };
}
export function cloneChapter(chapter: Chapter): Chapter {
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
@@ -140,11 +180,27 @@ export function createCover(): Cover {
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
}
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS };
export const SUPPORTED_LANGUAGES = ["en", "es", "fr", "de", "it", "pt"] as const;
export function clampLanguage(tag: string): string {
const primary = (tag || "").trim().split(/[-_]/)[0]?.toLowerCase() ?? "";
return (SUPPORTED_LANGUAGES as readonly string[]).includes(primary) ? primary : "en";
}
export function normalizeBook(book: Book): Book {
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
const metadata = { ...DEFAULT_METADATA, ...book.metadata };
return {
...book,
metadata: { ...metadata, language: clampLanguage(metadata.language) },
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
chapters: book.chapters.map((c) => (c.updatedAt ? c : { ...c, updatedAt: Date.now() })),
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
c.updatedAt ? c : { ...c, updatedAt: Date.now() },
),
};
}
@@ -152,9 +208,9 @@ export function createBook(): Book {
return {
schema: "margin/1",
id: crypto.randomUUID(),
metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" },
metadata: { ...DEFAULT_METADATA },
theme: "quiet-press",
settings: { trim: "6x9", bleed: true },
settings: { ...DEFAULT_SETTINGS },
cover: createCover(),
chapters: [createChapter("Chapter One")],
};
+106
View File
@@ -0,0 +1,106 @@
export type FontCategory = "serif" | "sans" | "display";
export interface BundledFont {
id: string;
label: string;
family: string;
category: FontCategory;
regular: string;
italic?: string;
weight: string;
}
export const BUNDLED_FONTS: BundledFont[] = [
{ id: "literata", label: "Literata", family: "Literata", category: "serif", regular: "Literata-VF.ttf", italic: "Literata-Italic-VF.ttf", weight: "200 900" },
{ id: "eb-garamond", label: "EB Garamond", family: "EB Garamond", category: "serif", regular: "EBGaramond-VF.ttf", italic: "EBGaramond-Italic-VF.ttf", weight: "400 800" },
{ id: "lora", label: "Lora", family: "Lora", category: "serif", regular: "Lora-VF.ttf", italic: "Lora-Italic-VF.ttf", weight: "400 700" },
{ id: "source-serif", label: "Source Serif 4", family: "Source Serif 4", category: "serif", regular: "SourceSerif4-VF.ttf", italic: "SourceSerif4-Italic-VF.ttf", weight: "200 900" },
{ id: "fraunces", label: "Fraunces", family: "Fraunces", category: "display", regular: "Fraunces-VF.ttf", italic: "Fraunces-Italic-VF.ttf", weight: "100 900" },
{ id: "hanken", label: "Hanken Grotesk", family: "Hanken Grotesk", category: "sans", regular: "HankenGrotesk-VF.ttf", italic: "HankenGrotesk-Italic-VF.ttf", weight: "100 900" },
];
export type FontRef =
| { kind: "bundled"; id: string }
| { kind: "system"; family: string };
export interface BookFonts {
body: FontRef;
heading: FontRef;
}
export interface FontPairing {
id: string;
label: string;
body: FontRef;
heading: FontRef;
}
const bundled = (id: string): FontRef => ({ kind: "bundled", id });
export const FONT_PAIRINGS: FontPairing[] = [
{ id: "quiet-press", label: "Quiet Press", body: bundled("literata"), heading: bundled("literata") },
{ id: "classic", label: "Classic", body: bundled("eb-garamond"), heading: bundled("eb-garamond") },
{ id: "editorial", label: "Editorial", body: bundled("source-serif"), heading: bundled("fraunces") },
{ id: "modern", label: "Modern", body: bundled("lora"), heading: bundled("hanken") },
{ id: "contrast", label: "Contrast", body: bundled("literata"), heading: bundled("hanken") },
];
export const DEFAULT_FONTS: BookFonts = { body: bundled("literata"), heading: bundled("literata") };
const SERIF_FALLBACK = `Georgia, "Times New Roman", serif`;
const SANS_FALLBACK = `ui-sans-serif, system-ui, -apple-system, sans-serif`;
export function bundledFont(id: string): BundledFont | undefined {
return BUNDLED_FONTS.find((f) => f.id === id);
}
export function encodeRef(ref: FontRef): string {
return ref.kind === "bundled" ? `b:${ref.id}` : `s:${ref.family}`;
}
export function decodeRef(value: string): FontRef {
return value.startsWith("s:") ? { kind: "system", family: value.slice(2) } : { kind: "bundled", id: value.slice(2) };
}
export function fontFamilyName(ref: FontRef): string {
if (ref.kind === "system") return ref.family;
return bundledFont(ref.id)?.family ?? "Literata";
}
export function fontLabel(ref: FontRef): string {
if (ref.kind === "system") return ref.family;
return bundledFont(ref.id)?.label ?? ref.id;
}
export function fontStack(ref: FontRef): string {
if (ref.kind === "bundled") {
const font = bundledFont(ref.id);
if (!font) return SERIF_FALLBACK;
return `"${font.family}", ${font.category === "sans" ? SANS_FALLBACK : SERIF_FALLBACK}`;
}
return `"${ref.family}", ${SERIF_FALLBACK}`;
}
export function refsEqual(a: FontRef, b: FontRef): boolean {
if (a.kind !== b.kind) return false;
return a.kind === "bundled" ? a.id === (b as { id: string }).id : a.family === (b as { family: string }).family;
}
export function pairingFor(fonts: BookFonts): string | null {
const match = FONT_PAIRINGS.find((p) => refsEqual(p.body, fonts.body) && refsEqual(p.heading, fonts.heading));
return match?.id ?? null;
}
export function fontsUsed(fonts: BookFonts): { bundled: BundledFont[]; system: string[] } {
const usedBundled = new Map<string, BundledFont>();
const usedSystem = new Set<string>();
for (const ref of [fonts.body, fonts.heading]) {
if (ref.kind === "bundled") {
const font = bundledFont(ref.id);
if (font) usedBundled.set(font.id, font);
} else {
usedSystem.add(ref.family);
}
}
return { bundled: [...usedBundled.values()], system: [...usedSystem] };
}
+156
View File
@@ -0,0 +1,156 @@
import { create } from "zustand";
import { openUrl } from "@tauri-apps/plugin-opener";
import {
type BackupStatus,
gdriveBackup,
gdriveConnect,
gdriveDisconnect,
gdriveRestore,
gdriveStatus,
} from "../backup";
import { isDesktop } from "../ipc";
import { useBook } from "./useBook";
type Phase = "idle" | "connecting" | "working" | "error";
interface BackupState {
connected: boolean;
email: string | null;
lastBackup: number | null;
pending: boolean;
phase: Phase;
error: string | null;
authUrl: string | null;
settingsOpen: boolean;
restoreNonce: number;
resolveConnect: ((ok: boolean) => void) | null;
apply: (status: BackupStatus) => void;
refresh: () => Promise<void>;
connect: () => Promise<boolean>;
cancelConnect: () => void;
handleAuthEvent: (ok: boolean, error: string | null) => Promise<void>;
openAuthUrl: () => void;
copyAuthUrl: () => Promise<void>;
disconnect: () => Promise<void>;
backup: (silent?: boolean) => Promise<void>;
restore: () => Promise<void>;
restoreFromDrive: () => Promise<void>;
openSettings: () => void;
closeSettings: () => void;
}
function notify(message: string) {
useBook.getState().setNotice(message);
}
export const useBackup = create<BackupState>((set, get) => ({
connected: false,
email: null,
lastBackup: null,
pending: false,
phase: "idle",
error: null,
authUrl: null,
settingsOpen: false,
restoreNonce: 0,
resolveConnect: null,
apply: (status) =>
set({
connected: status.connected,
email: status.email,
lastBackup: status.lastBackup,
pending: status.pending,
}),
refresh: async () => {
if (!isDesktop) return;
try {
get().apply(await gdriveStatus());
} catch {}
},
connect: () =>
new Promise<boolean>((resolve) => {
get().resolveConnect?.(false);
set({ phase: "connecting", error: null, authUrl: null, resolveConnect: resolve });
gdriveConnect()
.then((url) => set({ authUrl: url }))
.catch((e) => {
set({ phase: "error", error: String(e), resolveConnect: null });
notify(`Could not connect: ${e}`);
resolve(false);
});
}),
cancelConnect: () => {
const resolve = get().resolveConnect;
set({ phase: "idle", authUrl: null, resolveConnect: null });
resolve?.(false);
},
handleAuthEvent: async (ok, error) => {
if (get().phase !== "connecting") return;
const resolve = get().resolveConnect;
if (ok) {
await get().refresh();
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
notify("Connected to Google Drive");
} else {
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
notify(`Could not connect: ${error ?? "authorization failed"}`);
}
resolve?.(ok);
},
openAuthUrl: () => {
const url = get().authUrl;
if (url) openUrl(url).catch(() => {});
},
copyAuthUrl: async () => {
const url = get().authUrl;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
notify("Authorization link copied");
} catch {
notify("Could not copy the link");
}
},
disconnect: async () => {
set({ phase: "working", error: null });
try {
get().apply(await gdriveDisconnect());
set({ phase: "idle" });
notify("Disconnected from Google Drive");
} catch (e) {
set({ phase: "error", error: String(e) });
notify(`Could not disconnect: ${e}`);
}
},
backup: async (silent = false) => {
if (!get().connected || get().phase === "working") return;
set({ phase: "working", error: null });
try {
const outcome = await gdriveBackup();
get().apply(outcome);
set({ phase: "idle" });
if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up");
} catch (e) {
set({ phase: "error", error: String(e) });
if (!silent) notify(`Backup failed: ${e}`);
}
},
restore: async () => {
set({ phase: "working", error: null });
try {
const result = await gdriveRestore();
await get().refresh();
set((s) => ({ phase: "idle", restoreNonce: s.restoreNonce + 1 }));
notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive");
} catch (e) {
set({ phase: "error", error: String(e) });
notify(`Restore failed: ${e}`);
}
},
restoreFromDrive: async () => {
const ok = get().connected || (await get().connect());
if (ok) await get().restore();
},
openSettings: () => set({ settingsOpen: true }),
closeSettings: () => set({ settingsOpen: false }),
}));
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more