Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530e260903 | ||
|
|
74a684498a | ||
|
|
3fccb17060 | ||
|
|
9acb3aae3c | ||
|
|
fb6f14165f | ||
|
|
c79897c3db | ||
|
|
5cc083d670 | ||
|
|
ecc94e6c31 | ||
|
|
baf1154683 | ||
|
|
7a2d7c6aff | ||
|
|
9a38976000 | ||
|
|
28367b6670 | ||
|
|
124c1947c1 | ||
|
|
33ef53936e | ||
|
|
738eb9c9cf | ||
|
|
61bd7cf6e5 | ||
|
|
b4097cc47f | ||
|
|
72d698e427 | ||
|
|
8b83f80d52 | ||
|
|
39609fbd09 | ||
|
|
3177b2f1eb | ||
|
|
19c4823d3d | ||
|
|
67f70473ea | ||
|
|
5727592abe | ||
|
|
706db33792 | ||
|
|
bd44c0ba7e | ||
|
|
3c12d9857f |
No files matched your search
@@ -19,7 +19,7 @@ jobs:
|
|||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
release_id: ${{ steps.release.outputs.release_id }}
|
release_id: ${{ steps.release.outputs.release_id }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Determine version
|
- name: Determine version
|
||||||
id: version
|
id: version
|
||||||
@@ -53,8 +53,20 @@ jobs:
|
|||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml
|
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml
|
||||||
git commit -m "chore(release): $TAG"
|
git commit -m "chore(release): $TAG"
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
git fetch origin main
|
||||||
|
git rebase origin/main
|
||||||
|
if git push origin HEAD; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$attempt" = "5" ]; then
|
||||||
|
echo "::error::main kept advancing; could not push release bump after 5 attempts."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "main advanced during release; rebasing and retrying ($attempt)…"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
git tag "$TAG"
|
git tag "$TAG"
|
||||||
git push origin HEAD
|
|
||||||
git push origin "$TAG"
|
git push origin "$TAG"
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
@@ -71,7 +83,6 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 1
|
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
@@ -85,7 +96,7 @@ jobs:
|
|||||||
rust-targets: ""
|
rust-targets: ""
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.prepare.outputs.tag }}
|
ref: ${{ needs.prepare.outputs.tag }}
|
||||||
|
|
||||||
@@ -106,11 +117,11 @@ jobs:
|
|||||||
file \
|
file \
|
||||||
rpm
|
rpm
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 26
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
|
||||||
@@ -126,6 +137,19 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Provision Google credentials
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$GOOGLE_CREDENTIALS" ]; then
|
||||||
|
printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json
|
||||||
|
echo "Wrote google-credentials.json from GOOGLE_CREDENTIALS secret."
|
||||||
|
else
|
||||||
|
cp google-credentials.example.json google-credentials.json
|
||||||
|
echo "::warning::GOOGLE_CREDENTIALS secret not set — embedding placeholder credentials; Google Drive backup will be disabled in this release."
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build and upload
|
- name: Build and upload
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
@@ -140,9 +164,19 @@ jobs:
|
|||||||
needs: [prepare, build]
|
needs: [prepare, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Verify manifest is complete, then publish
|
||||||
- name: Publish release
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
TAG: ${{ needs.prepare.outputs.tag }}
|
TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
run: gh release edit "$TAG" --draft=false --latest
|
run: |
|
||||||
|
gh release download "$TAG" --repo "$REPO" --pattern latest.json --output latest.json --clobber
|
||||||
|
echo "Platforms in latest.json:"
|
||||||
|
jq '.platforms | keys' latest.json
|
||||||
|
for key in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
|
||||||
|
if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then
|
||||||
|
echo "::error::latest.json is missing platform '$key' — refusing to publish a partial update manifest. Re-run the release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
|
||||||
@@ -26,6 +26,10 @@ dist-ssr
|
|||||||
# Tauri build output
|
# Tauri build output
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
|
|
||||||
|
# Google OAuth desktop client (real values, never commit)
|
||||||
|
/google-credentials.json
|
||||||
|
/client_secret_*.json
|
||||||
|
|
||||||
# Screenshots & Playwright MCP artifacts
|
# Screenshots & Playwright MCP artifacts
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
/*.png
|
/*.png
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
## Git Commit Rules
|
## Git Commit Rules
|
||||||
|
|
||||||
|
- Do not make branches, commit in main only
|
||||||
- Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message`
|
- Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message`
|
||||||
- Never use `git add .` or `git add -A`. Always stage specific files by name.
|
- Never use `git add .` or `git add -A`. Always stage specific files by name.
|
||||||
- Don't batch multiple unrelated changes into one commit.
|
- Don't batch multiple unrelated changes into one commit.
|
||||||
- Commit early and often. A working 5-line change is better than a pending 200-line change.
|
|
||||||
@@ -1,37 +1,3 @@
|
|||||||
# margin
|
# margin
|
||||||
|
|
||||||
An offline desktop app for writing books and exporting them to print-ready PDF and reflowable EPUB — a focused, free alternative to InDesign for text-first books (novels, non-fiction).
|
Write your book. Own every word. A calm, offline studio to write, format, and publish your book to every store and to print.
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Tauri 2** (Rust shell) — offline by construction; the entire export pipeline runs locally, nothing hits the network.
|
|
||||||
- **React + TipTap** — the semantic writing surface.
|
|
||||||
- **Typst** (embedded as a Rust crate) — print-ready PDF with real book typography.
|
|
||||||
- **Built-in EPUB3** generator + zip packager.
|
|
||||||
|
|
||||||
One semantic document feeds three renderers — the editor, the PDF, and the EPUB — from a single design-token theme (**Quiet Press**: Literata for the page, Hanken Grotesk for the chrome). Both fonts are bundled.
|
|
||||||
|
|
||||||
## Develop
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm install
|
|
||||||
pnpm tauri dev # the real desktop app: live PDF dock, file dialogs, PDF/EPUB export
|
|
||||||
pnpm dev # browser-only UI preview (Tauri APIs are inert)
|
|
||||||
```
|
|
||||||
|
|
||||||
The first `tauri dev` build is slow because it compiles Typst.
|
|
||||||
|
|
||||||
## Using it
|
|
||||||
|
|
||||||
- A book saves as a single `.margin` file (JSON). Open / Save with ⌘O / ⌘S.
|
|
||||||
- Click the title in the top bar for **Book setup** (title, author, ISBN, trim size, language).
|
|
||||||
- Insert images from the toolbar and choose a placement (inline / full-width / full-page / float).
|
|
||||||
- Export to **PDF** or **EPUB** from the titlebar menu.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
- `src/editor/` — TipTap editor, extensions, figure block, floating toolbar.
|
|
||||||
- `src/components/` — app shell, preview dock, book-setup panel, pdf.js renderer.
|
|
||||||
- `src/export/` — `typst.ts` (book → Typst source), `epub.ts` (book → EPUB3 files), `exporters.ts`.
|
|
||||||
- `src/store/`, `src/model/` — document model and state.
|
|
||||||
- `src-tauri/src/` — `pdf.rs` (Typst compile), `epub.rs` (zip packager), `project.rs` (file IO).
|
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "margin-app",
|
"name": "margin-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -3089,8 +3089,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3754,6 +3756,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4632,6 +4635,12 @@ dependencies = [
|
|||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lzma-rs"
|
name = "lzma-rs"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -4704,12 +4713,16 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.0"
|
version = "0.1.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"fontdb",
|
||||||
"harper-core",
|
"harper-core",
|
||||||
|
"rand 0.8.6",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"spellbook",
|
"spellbook",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -4720,6 +4733,7 @@ dependencies = [
|
|||||||
"typst",
|
"typst",
|
||||||
"typst-as-lib",
|
"typst-as-lib",
|
||||||
"typst-pdf",
|
"typst-pdf",
|
||||||
|
"url",
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5966,6 +5980,61 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash 2.1.2",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash 2.1.2",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -5993,6 +6062,8 @@ version = "0.8.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha 0.3.1",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -6033,6 +6104,7 @@ version = "0.6.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6203,6 +6275,44 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -6425,6 +6535,7 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6773,6 +6884,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.21.0"
|
version = "3.21.0"
|
||||||
@@ -7421,7 +7544,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -7610,7 +7733,7 @@ dependencies = [
|
|||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"osakit",
|
"osakit",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"rustls",
|
"rustls",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -9149,6 +9272,15 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.1"
|
version = "0.1.6"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -27,6 +27,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
zip = "2"
|
zip = "2"
|
||||||
|
fontdb = "0.23"
|
||||||
typst = "0.14.2"
|
typst = "0.14.2"
|
||||||
typst-pdf = "0.14.2"
|
typst-pdf = "0.14.2"
|
||||||
typst-as-lib = "0.15.5"
|
typst-as-lib = "0.15.5"
|
||||||
@@ -35,6 +36,10 @@ spellbook = "0.4"
|
|||||||
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
|
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
|
||||||
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
|
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
|
||||||
harper-core = { version = "=2.5.0", features = ["concurrent"] }
|
harper-core = { version = "=2.5.0", features = ["concurrent"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
rand = "0.8"
|
||||||
|
url = "2"
|
||||||
|
|
||||||
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
|
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
|
||||||
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
|
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:window:allow-destroy",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ use std::io::{Read, Write};
|
|||||||
use zip::write::SimpleFileOptions;
|
use zip::write::SimpleFileOptions;
|
||||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||||
|
|
||||||
static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
|
|
||||||
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct EpubFile {
|
pub struct EpubFile {
|
||||||
path: String,
|
path: String,
|
||||||
@@ -48,14 +45,6 @@ fn build(files: &[EpubFile]) -> Result<Vec<u8>, String> {
|
|||||||
zip.write_all(&bytes).map_err(|e| e.to_string())?;
|
zip.write_all(&bytes).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (path, bytes) in [
|
|
||||||
("OEBPS/fonts/Literata-VF.ttf", LITERATA),
|
|
||||||
("OEBPS/fonts/Literata-Italic-VF.ttf", LITERATA_ITALIC),
|
|
||||||
] {
|
|
||||||
zip.start_file(path, deflated).map_err(|e| e.to_string())?;
|
|
||||||
zip.write_all(bytes).map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
mod epub;
|
mod epub;
|
||||||
|
mod fonts;
|
||||||
|
mod gdrive;
|
||||||
mod library;
|
mod library;
|
||||||
mod pdf;
|
mod pdf;
|
||||||
mod project;
|
mod project;
|
||||||
@@ -58,12 +60,24 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
let context = tauri::generate_context!();
|
||||||
|
|
||||||
|
let mut builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_process::init());
|
||||||
.plugin(tauri_plugin_process::init())
|
|
||||||
|
if context.config().plugins.0.contains_key("updater") {
|
||||||
|
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
.manage(proofing::new_state())
|
.manage(proofing::new_state())
|
||||||
|
.manage(gdrive::GDriveState::default())
|
||||||
|
.setup(|app| {
|
||||||
|
gdrive::init_session(app.handle());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.menu(|handle| build_menu(handle))
|
.menu(|handle| build_menu(handle))
|
||||||
.on_menu_event(|app, event| {
|
.on_menu_event(|app, event| {
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -76,6 +90,7 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
epub::package_epub,
|
epub::package_epub,
|
||||||
epub::unzip_epub,
|
epub::unzip_epub,
|
||||||
|
fonts::list_system_fonts,
|
||||||
pdf::compile_pdf,
|
pdf::compile_pdf,
|
||||||
project::read_file,
|
project::read_file,
|
||||||
project::write_file,
|
project::write_file,
|
||||||
@@ -85,8 +100,14 @@ pub fn run() {
|
|||||||
library::save_book,
|
library::save_book,
|
||||||
library::delete_book,
|
library::delete_book,
|
||||||
proofing::proof_text,
|
proofing::proof_text,
|
||||||
proofing::remember_word
|
proofing::remember_word,
|
||||||
|
gdrive::gdrive_connect,
|
||||||
|
gdrive::gdrive_disconnect,
|
||||||
|
gdrive::gdrive_status,
|
||||||
|
gdrive::gdrive_backup,
|
||||||
|
gdrive::gdrive_restore,
|
||||||
|
gdrive::gdrive_list_backups
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(context)
|
||||||
.expect("error while running margin");
|
.expect("error while running margin");
|
||||||
}
|
}
|
||||||
@@ -7,18 +7,37 @@ pub struct BookSummary {
|
|||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
author: String,
|
author: String,
|
||||||
|
corrupt: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
fn corrupt_summary(stem: &str) -> BookSummary {
|
||||||
let dir = app
|
BookSummary {
|
||||||
.path()
|
id: stem.to_string(),
|
||||||
.app_data_dir()
|
title: "Unreadable book".to_string(),
|
||||||
.map_err(|e| e.to_string())?
|
author: String::new(),
|
||||||
.join("library");
|
corrupt: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app_data_dir(app)?.join("library");
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn book_path(app: &tauri::AppHandle, id: &str) -> Result<PathBuf, String> {
|
||||||
|
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||||
|
return Err("invalid book id".to_string());
|
||||||
|
}
|
||||||
|
Ok(library_dir(app)?.join(format!("{id}.margin")))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
||||||
let dir = library_dir(&app)?;
|
let dir = library_dir(&app)?;
|
||||||
@@ -31,16 +50,24 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
|||||||
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
|
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
let contents = match fs::read_to_string(&path) {
|
let contents = match fs::read_to_string(&path) {
|
||||||
Ok(contents) => contents,
|
Ok(contents) => contents,
|
||||||
Err(_) => continue,
|
Err(_) => {
|
||||||
|
books.push(corrupt_summary(stem));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let value: serde_json::Value = match serde_json::from_str(&contents) {
|
let value: serde_json::Value = match serde_json::from_str(&contents) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(_) => continue,
|
Err(_) => {
|
||||||
|
books.push(corrupt_summary(stem));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let id = value.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
let id = value.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
if id.is_empty() {
|
if id.is_empty() {
|
||||||
|
books.push(corrupt_summary(stem));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let metadata = value.get("metadata");
|
let metadata = value.get("metadata");
|
||||||
@@ -58,6 +85,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
|||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
|
corrupt: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(books)
|
Ok(books)
|
||||||
@@ -65,19 +93,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn load_book(app: tauri::AppHandle, id: String) -> Result<String, String> {
|
pub fn load_book(app: tauri::AppHandle, id: String) -> Result<String, String> {
|
||||||
let path = library_dir(&app)?.join(format!("{id}.margin"));
|
let path = book_path(&app, &id)?;
|
||||||
fs::read_to_string(&path).map_err(|e| e.to_string())
|
fs::read_to_string(&path).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> {
|
pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> {
|
||||||
let path = library_dir(&app)?.join(format!("{id}.margin"));
|
let path = book_path(&app, &id)?;
|
||||||
fs::write(&path, contents).map_err(|e| e.to_string())
|
crate::project::atomic_write(&path, contents.as_bytes(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_book(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
pub fn delete_book(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||||
let path = library_dir(&app)?.join(format!("{id}.margin"));
|
let path = book_path(&app, &id)?;
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
fs::remove_file(&path).map_err(|e| e.to_string())?;
|
fs::remove_file(&path).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use typst::diag::{Severity, SourceDiagnostic};
|
use tauri::Emitter;
|
||||||
|
use typst::diag::{Severity, SourceDiagnostic, Warned};
|
||||||
use typst::layout::PagedDocument;
|
use typst::layout::PagedDocument;
|
||||||
use typst_as_lib::TypstEngine;
|
use typst_as_lib::TypstEngine;
|
||||||
|
|
||||||
@@ -9,6 +10,29 @@ static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
|
|||||||
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
||||||
static HANKEN: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-VF.ttf");
|
static HANKEN: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-VF.ttf");
|
||||||
static HANKEN_ITALIC: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-Italic-VF.ttf");
|
static HANKEN_ITALIC: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-Italic-VF.ttf");
|
||||||
|
static EB_GARAMOND: &[u8] = include_bytes!("../../public/fonts/EBGaramond-VF.ttf");
|
||||||
|
static EB_GARAMOND_ITALIC: &[u8] = include_bytes!("../../public/fonts/EBGaramond-Italic-VF.ttf");
|
||||||
|
static LORA: &[u8] = include_bytes!("../../public/fonts/Lora-VF.ttf");
|
||||||
|
static LORA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Lora-Italic-VF.ttf");
|
||||||
|
static SOURCE_SERIF: &[u8] = include_bytes!("../../public/fonts/SourceSerif4-VF.ttf");
|
||||||
|
static SOURCE_SERIF_ITALIC: &[u8] = include_bytes!("../../public/fonts/SourceSerif4-Italic-VF.ttf");
|
||||||
|
static FRAUNCES: &[u8] = include_bytes!("../../public/fonts/Fraunces-VF.ttf");
|
||||||
|
static FRAUNCES_ITALIC: &[u8] = include_bytes!("../../public/fonts/Fraunces-Italic-VF.ttf");
|
||||||
|
|
||||||
|
static EB_GARAMOND_FONTS: [&[u8]; 2] = [EB_GARAMOND, EB_GARAMOND_ITALIC];
|
||||||
|
static LORA_FONTS: [&[u8]; 2] = [LORA, LORA_ITALIC];
|
||||||
|
static SOURCE_SERIF_FONTS: [&[u8]; 2] = [SOURCE_SERIF, SOURCE_SERIF_ITALIC];
|
||||||
|
static FRAUNCES_FONTS: [&[u8]; 2] = [FRAUNCES, FRAUNCES_ITALIC];
|
||||||
|
|
||||||
|
fn bundled_font_bytes(id: &str) -> &'static [&'static [u8]] {
|
||||||
|
match id {
|
||||||
|
"eb-garamond" => &EB_GARAMOND_FONTS,
|
||||||
|
"lora" => &LORA_FONTS,
|
||||||
|
"source-serif" => &SOURCE_SERIF_FONTS,
|
||||||
|
"fraunces" => &FRAUNCES_FONTS,
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ImageInput {
|
pub struct ImageInput {
|
||||||
@@ -16,7 +40,12 @@ pub struct ImageInput {
|
|||||||
data: String,
|
data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
|
fn compile(
|
||||||
|
source: String,
|
||||||
|
images: &[ImageInput],
|
||||||
|
bundled_fonts: &[String],
|
||||||
|
system_fonts: &[String],
|
||||||
|
) -> Result<(Vec<u8>, String), String> {
|
||||||
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
|
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
|
||||||
for image in images {
|
for image in images {
|
||||||
let bytes = STANDARD
|
let bytes = STANDARD
|
||||||
@@ -25,20 +54,53 @@ fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
|
|||||||
binaries.push((image.path.as_str(), bytes));
|
binaries.push((image.path.as_str(), bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut fonts: Vec<Vec<u8>> = vec![
|
||||||
|
LITERATA.to_vec(),
|
||||||
|
LITERATA_ITALIC.to_vec(),
|
||||||
|
HANKEN.to_vec(),
|
||||||
|
HANKEN_ITALIC.to_vec(),
|
||||||
|
];
|
||||||
|
for id in bundled_fonts {
|
||||||
|
for bytes in bundled_font_bytes(id) {
|
||||||
|
fonts.push(bytes.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for family in system_fonts {
|
||||||
|
fonts.extend(crate::fonts::system_font_bytes(family));
|
||||||
|
}
|
||||||
|
|
||||||
let engine = TypstEngine::builder()
|
let engine = TypstEngine::builder()
|
||||||
.main_file(source)
|
.main_file(source)
|
||||||
.fonts([LITERATA, LITERATA_ITALIC, HANKEN, HANKEN_ITALIC])
|
.fonts(fonts)
|
||||||
.with_static_file_resolver(binaries)
|
.with_static_file_resolver(binaries)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let document: PagedDocument = engine.compile().output.map_err(|e| format_diagnostics(&e))?;
|
let Warned { output, warnings } = engine.compile();
|
||||||
|
let document: PagedDocument = output.map_err(|e| format_diagnostics(&e))?;
|
||||||
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))
|
let bytes =
|
||||||
|
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))?;
|
||||||
|
let warning_text = if warnings.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format_source_diagnostics(&warnings)
|
||||||
|
};
|
||||||
|
Ok((bytes, warning_text))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
pub fn compile_pdf(source: String, images: Vec<ImageInput>) -> Result<tauri::ipc::Response, String> {
|
pub fn compile_pdf(
|
||||||
compile(source, &images).map(tauri::ipc::Response::new)
|
app: tauri::AppHandle,
|
||||||
|
source: String,
|
||||||
|
images: Vec<ImageInput>,
|
||||||
|
emit_warnings: bool,
|
||||||
|
bundled_fonts: Vec<String>,
|
||||||
|
system_fonts: Vec<String>,
|
||||||
|
) -> Result<tauri::ipc::Response, String> {
|
||||||
|
let (bytes, warnings) = compile(source, &images, &bundled_fonts, &system_fonts)?;
|
||||||
|
if emit_warnings && !warnings.is_empty() {
|
||||||
|
app.emit("pdf-warnings", warnings).ok();
|
||||||
|
}
|
||||||
|
Ok(tauri::ipc::Response::new(bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String {
|
fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String {
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use std::fs;
|
use std::fs::{self, File};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||||
|
let mut name = path.as_os_str().to_owned();
|
||||||
|
name.push(suffix);
|
||||||
|
PathBuf::from(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn atomic_write(path: &Path, bytes: &[u8], backup: bool) -> Result<(), String> {
|
||||||
|
let tmp = with_suffix(path, ".tmp");
|
||||||
|
{
|
||||||
|
let mut f = File::create(&tmp).map_err(|e| e.to_string())?;
|
||||||
|
f.write_all(bytes).map_err(|e| e.to_string())?;
|
||||||
|
f.sync_all().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if path.exists() {
|
||||||
|
if backup {
|
||||||
|
let bak = with_suffix(path, ".bak");
|
||||||
|
let _ = fs::remove_file(&bak);
|
||||||
|
fs::rename(path, &bak).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::rename(&tmp, path).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn read_file(path: String) -> Result<String, String> {
|
pub fn read_file(path: String) -> Result<String, String> {
|
||||||
@@ -9,11 +36,11 @@ pub fn read_file(path: String) -> Result<String, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn write_file(path: String, contents: String) -> Result<(), String> {
|
pub fn write_file(path: String, contents: String) -> Result<(), String> {
|
||||||
fs::write(&path, contents).map_err(|e| e.to_string())
|
atomic_write(Path::new(&path), contents.as_bytes(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn write_bytes(path: String, data: String) -> Result<(), String> {
|
pub fn write_bytes(path: String, data: String) -> Result<(), String> {
|
||||||
let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_string())?;
|
let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_string())?;
|
||||||
fs::write(&path, bytes).map_err(|e| e.to_string())
|
atomic_write(Path::new(&path), &bytes, false)
|
||||||
}
|
}
|
||||||
@@ -176,36 +176,40 @@ fn collect_grammar(harper: &mut Harper, text: &str, chars: &[char], issues: &mut
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn proof_text(
|
pub async fn proof_text(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
state: tauri::State<'_, ProofState>,
|
|
||||||
text: String,
|
text: String,
|
||||||
spelling: bool,
|
spelling: bool,
|
||||||
grammar: bool,
|
grammar: bool,
|
||||||
) -> Result<Vec<Issue>, String> {
|
) -> Result<Vec<Issue>, String> {
|
||||||
let mut guard = state.inner().lock().map_err(|e| e.to_string())?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<Vec<Issue>, String> {
|
||||||
if guard.is_none() {
|
let state = app.state::<ProofState>();
|
||||||
*guard = Some(Engine {
|
let mut guard = state.lock().map_err(|e| e.to_string())?;
|
||||||
speller: None,
|
if guard.is_none() {
|
||||||
harper: None,
|
*guard = Some(Engine {
|
||||||
custom: load_custom(&app),
|
speller: None,
|
||||||
});
|
harper: None,
|
||||||
}
|
custom: load_custom(&app),
|
||||||
let engine = guard.as_mut().unwrap();
|
});
|
||||||
|
}
|
||||||
|
let engine = guard.as_mut().unwrap();
|
||||||
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
let chars: Vec<char> = text.chars().collect();
|
||||||
let mut issues = Vec::new();
|
let mut issues = Vec::new();
|
||||||
if spelling {
|
if spelling {
|
||||||
if engine.speller.is_none() {
|
if engine.speller.is_none() {
|
||||||
engine.speller = Some(build_speller(&app)?);
|
engine.speller = Some(build_speller(&app)?);
|
||||||
|
}
|
||||||
|
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
|
||||||
}
|
}
|
||||||
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
|
if grammar {
|
||||||
}
|
if engine.harper.is_none() {
|
||||||
if grammar {
|
engine.harper = Some(build_harper());
|
||||||
if engine.harper.is_none() {
|
}
|
||||||
engine.harper = Some(build_harper());
|
collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues);
|
||||||
}
|
}
|
||||||
collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues);
|
Ok(issues)
|
||||||
}
|
})
|
||||||
Ok(issues)
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "margin",
|
"productName": "margin",
|
||||||
"version": "0.1.1",
|
"version": "0.1.6",
|
||||||
"identifier": "studio.margin.app",
|
"identifier": "studio.margin.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self'; img-src 'self' data: blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; connect-src 'self' ipc: http://ipc.localhost"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { Library } from "./components/Library";
|
import { Library } from "./components/Library";
|
||||||
import { EditorView } from "./components/EditorView";
|
import { EditorView } from "./components/EditorView";
|
||||||
|
import { BackupSettings } from "./components/BackupSettings";
|
||||||
import { useBook } from "./store/useBook";
|
import { useBook } from "./store/useBook";
|
||||||
|
import { useBackup } from "./store/useBackup";
|
||||||
import { isDesktop } from "./ipc";
|
import { isDesktop } from "./ipc";
|
||||||
import { newBook } from "./library";
|
import { createAndOpenBook, saveBook } from "./library";
|
||||||
import { runExport } from "./export/run";
|
import { runExport } from "./export/run";
|
||||||
import { checkForUpdates } from "./updater";
|
import { checkForUpdates } from "./updater";
|
||||||
|
|
||||||
@@ -17,17 +20,70 @@ function App() {
|
|||||||
checkForUpdates(true);
|
checkForUpdates(true);
|
||||||
const unlisten = listen<string>("menu-action", (event) => {
|
const unlisten = listen<string>("menu-action", (event) => {
|
||||||
const state = useBook.getState();
|
const state = useBook.getState();
|
||||||
if (event.payload === "new-book") state.openBook(newBook());
|
if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice);
|
||||||
else if (event.payload === "export-pdf") runExport("pdf");
|
else if (event.payload === "export-pdf") runExport("pdf");
|
||||||
else if (event.payload === "export-epub") runExport("epub");
|
else if (event.payload === "export-epub") runExport("epub");
|
||||||
else if (event.payload === "check-updates") checkForUpdates(false);
|
else if (event.payload === "check-updates") checkForUpdates(false);
|
||||||
});
|
});
|
||||||
|
const unlistenWarn = listen<string>("pdf-warnings", (event) => {
|
||||||
|
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unlisten.then((stop) => stop());
|
||||||
|
unlistenWarn.then((stop) => stop());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktop) return;
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
const unlisten = win.onCloseRequested(async (event) => {
|
||||||
|
const { book, dirty } = useBook.getState();
|
||||||
|
const { connected } = useBackup.getState();
|
||||||
|
if ((!book || !dirty) && !connected) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (book && dirty) await saveBook(book).catch(() => {});
|
||||||
|
if (connected) {
|
||||||
|
await Promise.race([
|
||||||
|
useBackup.getState().backup(true),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 8000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
win.destroy();
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unlisten.then((stop) => stop());
|
unlisten.then((stop) => stop());
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return book ? <EditorView /> : <Library onOpen={openBook} />;
|
useEffect(() => {
|
||||||
|
if (!isDesktop) return;
|
||||||
|
const unlisten = listen<{ ok: boolean; error: string | null }>("gdrive-auth", (event) => {
|
||||||
|
useBackup.getState().handleAuthEvent(event.payload.ok, event.payload.error);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unlisten.then((stop) => stop());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktop) return;
|
||||||
|
const tick = async () => {
|
||||||
|
await useBackup.getState().refresh();
|
||||||
|
const state = useBackup.getState();
|
||||||
|
if (state.connected && state.pending) state.backup(true);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, 15 * 60 * 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
||||||
|
{isDesktop && <BackupSettings />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -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()}`;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import type { Device } from "../devices";
|
||||||
|
import type { Book } from "../model/book";
|
||||||
|
import { CoverArt } from "./CoverArt";
|
||||||
|
|
||||||
|
interface DeviceFrameProps {
|
||||||
|
device: Device;
|
||||||
|
eyebrow?: ReactNode;
|
||||||
|
title?: ReactNode;
|
||||||
|
html?: string;
|
||||||
|
cover?: Book;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameVars(device: Device): CSSProperties {
|
||||||
|
return {
|
||||||
|
"--dv-bg": device.bg,
|
||||||
|
"--dv-ink": device.ink,
|
||||||
|
"--dv-fs": device.fontSize,
|
||||||
|
"--dv-lh": device.lineHeight,
|
||||||
|
"--dv-mx": device.marginX,
|
||||||
|
"--dv-my": device.marginY,
|
||||||
|
"--dv-bezel": device.bezel,
|
||||||
|
"--dv-bezel-w": device.bezelWidth,
|
||||||
|
"--dv-radius": device.radius,
|
||||||
|
"--dv-aspect": device.aspect,
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBar() {
|
||||||
|
return (
|
||||||
|
<div className="device-statusbar">
|
||||||
|
<span className="device-clock">9:41</span>
|
||||||
|
<span className="device-batt" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceFrame({ device, eyebrow, title, html, cover }: DeviceFrameProps) {
|
||||||
|
return (
|
||||||
|
<div className="device-stage">
|
||||||
|
<div className="device-frame" data-kind={device.kind} style={frameVars(device)}>
|
||||||
|
<div className="device-screen">
|
||||||
|
{device.kind === "tablet" && <StatusBar />}
|
||||||
|
<div className="device-content">
|
||||||
|
{cover ? (
|
||||||
|
<CoverArt book={cover} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="device-opener">
|
||||||
|
{eyebrow && <div className="device-eyebrow">{eyebrow}</div>}
|
||||||
|
{title && <div className="device-title">{title}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="device-body" dangerouslySetInnerHTML={{ __html: html ?? "" }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{device.kind === "eink" && (
|
||||||
|
<div className="device-footer">
|
||||||
|
<span className="device-foot-title">{cover ? device.label : title}</span>
|
||||||
|
<span className="device-batt" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { generateHTML } from "@tiptap/core";
|
import { generateHTML } from "@tiptap/core";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import { type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
import { type Chapter, type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
||||||
import { editorExtensions } from "../editor/extensions";
|
import { editorExtensions } from "../editor/extensions";
|
||||||
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||||
import { compilePdf, isDesktop } from "../ipc";
|
import { compilePdf, isDesktop } from "../ipc";
|
||||||
|
import { DEVICES, type Device, findDevice } from "../devices";
|
||||||
|
import { usePreviewMode } from "../store/usePreviewMode";
|
||||||
import { PdfPreview } from "./PdfPreview";
|
import { PdfPreview } from "./PdfPreview";
|
||||||
import { CoverArt } from "./CoverArt";
|
import { CoverArt } from "./CoverArt";
|
||||||
|
import { DeviceFrame } from "./DeviceFrame";
|
||||||
|
|
||||||
const TRIMS: { value: TrimSize; label: string }[] = [
|
const TRIMS: { value: TrimSize; label: string }[] = [
|
||||||
{ value: "6x9", label: "6 × 9 in" },
|
{ value: "6x9", label: "6 × 9 in" },
|
||||||
@@ -15,26 +18,77 @@ const TRIMS: { value: TrimSize; label: string }[] = [
|
|||||||
{ value: "a5", label: "A5" },
|
{ value: "a5", label: "A5" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function TrimSelect() {
|
function PreviewSelect() {
|
||||||
const trim = useBook((s) => s.book?.settings.trim ?? "6x9");
|
const trim = useBook((s) => s.book?.settings.trim ?? "6x9");
|
||||||
const setSettings = useBook((s) => s.setSettings);
|
const setSettings = useBook((s) => s.setSettings);
|
||||||
|
const mode = usePreviewMode((s) => s.mode);
|
||||||
|
const setMode = usePreviewMode((s) => s.setMode);
|
||||||
|
const value = mode === "print" ? trim : mode;
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className="trim-select"
|
className="trim-select"
|
||||||
value={trim}
|
value={value}
|
||||||
onChange={(e) => setSettings({ trim: e.target.value as TrimSize })}
|
onChange={(e) => {
|
||||||
title="Trim size"
|
const v = e.target.value;
|
||||||
|
if (findDevice(v)) {
|
||||||
|
setMode(v as Device["id"]);
|
||||||
|
} else {
|
||||||
|
setSettings({ trim: v as TrimSize });
|
||||||
|
setMode("print");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Preview format"
|
||||||
>
|
>
|
||||||
{TRIMS.map((t) => (
|
<optgroup label="Print">
|
||||||
<option key={t.value} value={t.value}>
|
{TRIMS.map((t) => (
|
||||||
{t.label}
|
<option key={t.value} value={t.value}>
|
||||||
</option>
|
{t.label}
|
||||||
))}
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Devices">
|
||||||
|
{DEVICES.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DockHead({ meta }: { meta?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="dock-head">
|
||||||
|
<span className="label">Preview</span>
|
||||||
|
<div className="dock-tools">
|
||||||
|
{meta}
|
||||||
|
<PreviewSelect />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
|
||||||
|
const kind = chapterKind(chapter);
|
||||||
|
return kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||||
|
}
|
||||||
|
|
||||||
|
function useActiveChapter() {
|
||||||
|
const book = useBook((s) => s.book);
|
||||||
|
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||||
|
const chapters = book?.chapters ?? [];
|
||||||
|
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
|
const chapter = chapters[idx] ?? chapters[0];
|
||||||
|
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
|
||||||
|
const eyebrow = chapter ? chapterEyebrow(chapter, chapters, idx) : "";
|
||||||
|
return { book, coverActive: activeChapterId === COVER_ID, chapters, idx, chapter, html, eyebrow };
|
||||||
|
}
|
||||||
|
|
||||||
export function Dock() {
|
export function Dock() {
|
||||||
|
const mode = usePreviewMode((s) => s.mode);
|
||||||
|
const device = findDevice(mode);
|
||||||
|
if (device) return <DeviceDock device={device} />;
|
||||||
return isDesktop ? <PdfDock /> : <HtmlDock />;
|
return isDesktop ? <PdfDock /> : <HtmlDock />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +107,8 @@ function PdfDock() {
|
|||||||
clearTimeout(timer.current);
|
clearTimeout(timer.current);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
timer.current = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
const { source, images } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
const { source, images, fonts } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
||||||
compilePdf(source, images)
|
compilePdf(source, images, false, fonts)
|
||||||
.then((bytes) => {
|
.then((bytes) => {
|
||||||
setPdf(bytes);
|
setPdf(bytes);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -67,13 +121,7 @@ function PdfDock() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dock">
|
<section className="dock">
|
||||||
<div className="dock-head">
|
<DockHead meta={busy ? <span className="meta">updating…</span> : null} />
|
||||||
<span className="label">Preview</span>
|
|
||||||
<div className="dock-tools">
|
|
||||||
{busy && <span className="meta">updating…</span>}
|
|
||||||
<TrimSelect />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<pre className="dock-error">{error}</pre>
|
<pre className="dock-error">{error}</pre>
|
||||||
) : pdf ? (
|
) : pdf ? (
|
||||||
@@ -86,26 +134,14 @@ function PdfDock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HtmlDock() {
|
function HtmlDock() {
|
||||||
const book = useBook((s) => s.book);
|
const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
|
||||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
|
||||||
const coverActive = activeChapterId === COVER_ID;
|
|
||||||
const chapters = book?.chapters ?? [];
|
|
||||||
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
|
||||||
const chapter = chapters[idx] ?? chapters[0];
|
|
||||||
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
|
|
||||||
|
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
|
|
||||||
if (coverActive) {
|
if (coverActive) {
|
||||||
return (
|
return (
|
||||||
<section className="dock">
|
<section className="dock">
|
||||||
<div className="dock-head">
|
<DockHead meta={<span className="meta">Cover</span>} />
|
||||||
<span className="label">Preview</span>
|
|
||||||
<div className="dock-tools">
|
|
||||||
<span className="meta">Cover</span>
|
|
||||||
<TrimSelect />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="cover-stage">
|
<div className="cover-stage">
|
||||||
<CoverArt book={book} />
|
<CoverArt book={book} />
|
||||||
</div>
|
</div>
|
||||||
@@ -115,21 +151,15 @@ function HtmlDock() {
|
|||||||
|
|
||||||
if (!chapter) return null;
|
if (!chapter) return null;
|
||||||
|
|
||||||
const kind = chapterKind(chapter);
|
|
||||||
const eyebrow =
|
|
||||||
kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dock">
|
<section className="dock">
|
||||||
<div className="dock-head">
|
<DockHead
|
||||||
<span className="label">Preview</span>
|
meta={
|
||||||
<div className="dock-tools">
|
|
||||||
<span className="meta">
|
<span className="meta">
|
||||||
{idx + 1} of {chapters.length}
|
{idx + 1} of {chapters.length}
|
||||||
</span>
|
</span>
|
||||||
<TrimSelect />
|
}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="p-opener">
|
<div className="p-opener">
|
||||||
<div className="p-num">{eyebrow}</div>
|
<div className="p-num">{eyebrow}</div>
|
||||||
@@ -141,3 +171,45 @@ function HtmlDock() {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DeviceDock({ device }: { device: Device }) {
|
||||||
|
const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
|
||||||
|
|
||||||
|
if (!book) return null;
|
||||||
|
|
||||||
|
if (coverActive) {
|
||||||
|
return (
|
||||||
|
<section className="dock">
|
||||||
|
<DockHead meta={<span className="meta">Cover</span>} />
|
||||||
|
<DeviceFrame device={device} cover={book} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chapter) {
|
||||||
|
return (
|
||||||
|
<section className="dock">
|
||||||
|
<DockHead />
|
||||||
|
<div className="dock-empty">No chapter selected.</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="dock">
|
||||||
|
<DockHead
|
||||||
|
meta={
|
||||||
|
<span className="meta">
|
||||||
|
{idx + 1} of {chapters.length}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DeviceFrame
|
||||||
|
device={device}
|
||||||
|
eyebrow={eyebrow}
|
||||||
|
title={chapter.noTitle ? undefined : chapter.title || "Untitled"}
|
||||||
|
html={html}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Dock } from "./Dock";
|
|||||||
import { ResizeHandle } from "./ResizeHandle";
|
import { ResizeHandle } from "./ResizeHandle";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
|
import { BackupButton } from "./BackupButton";
|
||||||
import { CoverView } from "./CoverView";
|
import { CoverView } from "./CoverView";
|
||||||
import { FindBar } from "./FindBar";
|
import { FindBar } from "./FindBar";
|
||||||
import { ProofPopover } from "./ProofPopover";
|
import { ProofPopover } from "./ProofPopover";
|
||||||
@@ -12,6 +13,7 @@ import { Editor } from "../editor/Editor";
|
|||||||
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||||
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
|
import { useBackup } from "../store/useBackup";
|
||||||
import { useProofing } from "../store/useProofing";
|
import { useProofing } from "../store/useProofing";
|
||||||
import { useTheme } from "../store/useTheme";
|
import { useTheme } from "../store/useTheme";
|
||||||
import { useWidth } from "../store/useWidth";
|
import { useWidth } from "../store/useWidth";
|
||||||
@@ -75,8 +77,14 @@ export function EditorView() {
|
|||||||
|
|
||||||
const saveNow = useCallback(() => {
|
const saveNow = useCallback(() => {
|
||||||
const current = useBook.getState().book;
|
const current = useBook.getState().book;
|
||||||
if (current) saveBook(current).then(markSaved).catch((e) => setNotice(`Save failed: ${e}`));
|
if (!current) return;
|
||||||
}, [markSaved]);
|
saveBook(current)
|
||||||
|
.then(() => {
|
||||||
|
if (useBook.getState().book === current) markSaved();
|
||||||
|
useBackup.getState().refresh();
|
||||||
|
})
|
||||||
|
.catch((e) => setNotice(`Save failed: ${e}`));
|
||||||
|
}, [markSaved, setNotice]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!book || !dirty || !isDesktop) return;
|
if (!book || !dirty || !isDesktop) return;
|
||||||
@@ -158,6 +166,7 @@ export function EditorView() {
|
|||||||
{dirty && <span className="dirty-dot" />}
|
{dirty && <span className="dirty-dot" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
{isDesktop && <BackupButton />}
|
||||||
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
||||||
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -263,10 +272,15 @@ export function EditorView() {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<Editor
|
<Editor
|
||||||
|
key={chapter.id}
|
||||||
|
bookId={book.id}
|
||||||
chapterId={chapter.id}
|
chapterId={chapter.id}
|
||||||
content={chapter.content}
|
content={chapter.content}
|
||||||
onChange={(content) => setChapterContent(chapter.id, content)}
|
onChange={(content) => setChapterContent(chapter.id, content)}
|
||||||
onReady={setEditor}
|
onReady={setEditor}
|
||||||
|
onContentError={() =>
|
||||||
|
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
<FloatingToolbar editor={editor} />
|
<FloatingToolbar editor={editor} />
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { type BookSummary, deleteBook, exampleBook, listBooks, loadBook, newBook, saveBook } from "../library";
|
import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library";
|
||||||
import type { Book } from "../model/book";
|
import type { Book } from "../model/book";
|
||||||
import { importEpub } from "../import/epub";
|
import { importEpub } from "../import/epub";
|
||||||
|
import { clearPositions } from "../editor/positions";
|
||||||
import { isDesktop } from "../ipc";
|
import { isDesktop } from "../ipc";
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { RowMenu } from "./RowMenu";
|
import { RowMenu } from "./RowMenu";
|
||||||
|
import { BackupButton } from "./BackupButton";
|
||||||
|
import { useBackup } from "../store/useBackup";
|
||||||
|
import { useBook } from "../store/useBook";
|
||||||
|
|
||||||
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||||
const [books, setBooks] = useState<BookSummary[]>([]);
|
const [books, setBooks] = useState<BookSummary[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
|
||||||
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
||||||
|
const notice = useBook((s) => s.notice);
|
||||||
|
const setNotice = useBook((s) => s.setNotice);
|
||||||
|
const restoreNonce = useBackup((s) => s.restoreNonce);
|
||||||
|
const restoreWorking = useBackup((s) => s.phase === "working");
|
||||||
|
const restoreFromDrive = useBackup((s) => s.restoreFromDrive);
|
||||||
|
|
||||||
const refresh = () =>
|
const refresh = () =>
|
||||||
listBooks()
|
listBooks()
|
||||||
@@ -22,6 +30,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (restoreNonce) refresh();
|
||||||
|
}, [restoreNonce]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notice) return;
|
||||||
|
const timer = setTimeout(() => setNotice(null), 4000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [notice]);
|
||||||
|
|
||||||
const handleExample = async () => {
|
const handleExample = async () => {
|
||||||
const copy = exampleBook();
|
const copy = exampleBook();
|
||||||
@@ -38,6 +54,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
const removeBook = async () => {
|
const removeBook = async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
await deleteBook(pendingDelete.id);
|
await deleteBook(pendingDelete.id);
|
||||||
|
clearPositions(pendingDelete.id);
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
@@ -62,9 +79,11 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="library">
|
<div className="library">
|
||||||
<header className="library-head" data-tauri-drag-region />
|
<header className="library-head" data-tauri-drag-region>
|
||||||
|
{isDesktop && <BackupButton />}
|
||||||
|
</header>
|
||||||
<div className="shelf">
|
<div className="shelf">
|
||||||
<button className="card card-action" onClick={() => onOpen(newBook())}>
|
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
||||||
<Icon d="M12 5v14M5 12h14" size={20} />
|
<Icon d="M12 5v14M5 12h14" size={20} />
|
||||||
<span>New book</span>
|
<span>New book</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -78,14 +97,34 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
<span className="card-badge">Example</span>
|
<span className="card-badge">Example</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{books.map((b) => (
|
{books.map((b) =>
|
||||||
<div key={b.id} className="card card-book" onClick={() => loadBook(b.id).then(onOpen)}>
|
b.corrupt ? (
|
||||||
<span className="card-title">{b.title || "Untitled"}</span>
|
<div key={b.id} className="card card-book card-corrupt">
|
||||||
{b.author && <span className="card-author">{b.author}</span>}
|
<span className="card-title">{b.title}</span>
|
||||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
<span className="card-author">Couldn't be read; a .bak backup may sit beside it.</span>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
{loaded && books.length === 0 && isDesktop && (
|
||||||
|
<div className="library-restore">
|
||||||
|
<button className="restore-link" onClick={restoreFromDrive} disabled={restoreWorking}>
|
||||||
|
<Icon d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" size={15} />
|
||||||
|
{restoreWorking ? "Restoring…" : "Restore from Google Drive"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{notice && (
|
{notice && (
|
||||||
<div className="toast" onClick={() => setNotice(null)}>
|
<div className="toast" onClick={() => setNotice(null)}>
|
||||||
{notice}
|
{notice}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||||
|
|
||||||
@@ -6,42 +6,56 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
|||||||
|
|
||||||
export function PdfPreview({ data }: { data: Uint8Array }) {
|
export function PdfPreview({ data }: { data: Uint8Array }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = ref.current;
|
const container = ref.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const loadingTask = pdfjs.getDocument({ data: data.slice() });
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const doc = await pdfjs.getDocument({ data: data.slice() }).promise;
|
try {
|
||||||
if (cancelled) return;
|
const doc = await loadingTask.promise;
|
||||||
const width = container.clientWidth;
|
if (cancelled) return;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const width = container.clientWidth;
|
||||||
const canvases: HTMLCanvasElement[] = [];
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const canvases: HTMLCanvasElement[] = [];
|
||||||
|
|
||||||
for (let n = 1; n <= doc.numPages; n++) {
|
for (let n = 1; n <= doc.numPages; n++) {
|
||||||
const page = await doc.getPage(n);
|
const page = await doc.getPage(n);
|
||||||
|
if (cancelled) return;
|
||||||
|
const base = page.getViewport({ scale: 1 });
|
||||||
|
const scale = width / base.width;
|
||||||
|
const viewport = page.getViewport({ scale: scale * dpr });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.className = "pdf-page";
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.style.width = "100%";
|
||||||
|
await page.render({ canvas, viewport }).promise;
|
||||||
|
if (cancelled) return;
|
||||||
|
canvases.push(canvas);
|
||||||
|
}
|
||||||
|
container.replaceChildren(...canvases);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const base = page.getViewport({ scale: 1 });
|
container.replaceChildren();
|
||||||
const scale = width / base.width;
|
setError(String(e));
|
||||||
const viewport = page.getViewport({ scale: scale * dpr });
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.className = "pdf-page";
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
canvas.style.width = "100%";
|
|
||||||
const context = canvas.getContext("2d")!;
|
|
||||||
await page.render({ canvas, canvasContext: context, viewport }).promise;
|
|
||||||
if (cancelled) return;
|
|
||||||
canvases.push(canvas);
|
|
||||||
}
|
}
|
||||||
container.replaceChildren(...canvases);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
loadingTask.destroy();
|
||||||
};
|
};
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return <div className="pdf-pages" ref={ref} />;
|
return (
|
||||||
|
<>
|
||||||
|
{error && <pre className="dock-error">{error}</pre>}
|
||||||
|
<div className="pdf-pages" ref={ref} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useBook } from "../store/useBook";
|
import { useBook } from "../store/useBook";
|
||||||
|
import { listSystemFonts } from "../ipc";
|
||||||
|
import {
|
||||||
|
type BookFonts,
|
||||||
|
type FontRef,
|
||||||
|
BUNDLED_FONTS,
|
||||||
|
DEFAULT_FONTS,
|
||||||
|
FONT_PAIRINGS,
|
||||||
|
decodeRef,
|
||||||
|
encodeRef,
|
||||||
|
fontStack,
|
||||||
|
pairingFor,
|
||||||
|
} from "../model/fonts";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
@@ -12,6 +24,8 @@ const LANGUAGES = [
|
|||||||
{ value: "pt", label: "Portuguese" },
|
{ value: "pt", label: "Portuguese" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let systemFontCache: string[] | null = null;
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
@@ -19,6 +33,7 @@ interface Draft {
|
|||||||
isbn: string;
|
isbn: string;
|
||||||
language: string;
|
language: string;
|
||||||
bleed: boolean;
|
bleed: boolean;
|
||||||
|
fonts: BookFonts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
@@ -30,6 +45,30 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FontSelect({ value, system, onChange }: { value: FontRef; system: string[]; onChange: (ref: FontRef) => void }) {
|
||||||
|
const extra = value.kind === "system" && !system.includes(value.family) ? [value.family] : [];
|
||||||
|
return (
|
||||||
|
<select value={encodeRef(value)} style={{ fontFamily: fontStack(value) }} onChange={(e) => onChange(decodeRef(e.target.value))}>
|
||||||
|
<optgroup label="Bundled">
|
||||||
|
{BUNDLED_FONTS.map((f) => (
|
||||||
|
<option key={f.id} value={`b:${f.id}`}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
{(system.length > 0 || extra.length > 0) && (
|
||||||
|
<optgroup label="System">
|
||||||
|
{[...extra, ...system].map((name) => (
|
||||||
|
<option key={name} value={`s:${name}`}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
|
export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
|
||||||
const book = useBook((s) => s.book);
|
const book = useBook((s) => s.book);
|
||||||
const setMetadata = useBook((s) => s.setMetadata);
|
const setMetadata = useBook((s) => s.setMetadata);
|
||||||
@@ -42,16 +81,31 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
|||||||
isbn: book?.metadata.isbn ?? "",
|
isbn: book?.metadata.isbn ?? "",
|
||||||
language: book?.metadata.language ?? "en",
|
language: book?.metadata.language ?? "en",
|
||||||
bleed: book?.settings.bleed ?? true,
|
bleed: book?.settings.bleed ?? true,
|
||||||
|
fonts: book?.settings.fonts ?? DEFAULT_FONTS,
|
||||||
}));
|
}));
|
||||||
|
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
|
||||||
|
const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (systemFontCache) return;
|
||||||
|
listSystemFonts().then((fonts) => {
|
||||||
|
systemFontCache = fonts;
|
||||||
|
setSystem(fonts);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
|
|
||||||
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||||
|
const setFont = (slot: "body" | "heading", ref: FontRef) => set({ fonts: { ...draft.fonts, [slot]: ref } });
|
||||||
|
|
||||||
|
const activePreset = pairingFor(draft.fonts);
|
||||||
|
const usesSystem = draft.fonts.body.kind === "system" || draft.fonts.heading.kind === "system";
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const { bleed, ...metadata } = draft;
|
const { bleed, fonts, ...metadata } = draft;
|
||||||
setMetadata(metadata);
|
setMetadata(metadata);
|
||||||
setSettings({ bleed });
|
setSettings({ bleed, fonts });
|
||||||
onSave();
|
onSave();
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -87,6 +141,51 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<span className="field-label">Typography</span>
|
||||||
|
<div className="font-presets">
|
||||||
|
{FONT_PAIRINGS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className="font-preset"
|
||||||
|
data-on={activePreset === p.id}
|
||||||
|
onClick={() => set({ fonts: { body: p.body, heading: p.heading } })}
|
||||||
|
>
|
||||||
|
<span className="font-preset-demo" style={{ fontFamily: fontStack(p.heading) }}>
|
||||||
|
Ag
|
||||||
|
</span>
|
||||||
|
<span className="font-preset-name">{p.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="font-advanced" onClick={() => setAdvanced((v) => !v)}>
|
||||||
|
{advanced ? "Hide custom fonts" : "Customize fonts"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{advanced && (
|
||||||
|
<>
|
||||||
|
<Field label="Body font">
|
||||||
|
<FontSelect value={draft.fonts.body} system={system} onChange={(ref) => setFont("body", ref)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Heading font">
|
||||||
|
<FontSelect value={draft.fonts.heading} system={system} onChange={(ref) => setFont("heading", ref)} />
|
||||||
|
</Field>
|
||||||
|
{usesSystem && (
|
||||||
|
<p className="font-note">System fonts preview here but may not embed in exported PDF or EPUB, so they can look different on other devices.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="font-sample" style={{ fontFamily: fontStack(draft.fonts.body) }}>
|
||||||
|
<span className="font-sample-title" style={{ fontFamily: fontStack(draft.fonts.heading) }}>
|
||||||
|
Chapter One
|
||||||
|
</span>
|
||||||
|
She read the first line twice, and the whole quiet house seemed to lean in to listen.
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="check">
|
<label className="check">
|
||||||
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
|
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
|
||||||
Add bleed for full-page images (print)
|
Add bleed for full-page images (print)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
export type DeviceId = "kindle" | "kobo" | "ipad" | "iphone" | "android";
|
||||||
|
export type PreviewMode = "print" | DeviceId;
|
||||||
|
export type DeviceKind = "eink" | "phone" | "tablet";
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
id: DeviceId;
|
||||||
|
label: string;
|
||||||
|
kind: DeviceKind;
|
||||||
|
bg: string;
|
||||||
|
ink: string;
|
||||||
|
fontSize: number;
|
||||||
|
lineHeight: number;
|
||||||
|
marginX: number;
|
||||||
|
marginY: number;
|
||||||
|
aspect: number;
|
||||||
|
bezel: string;
|
||||||
|
bezelWidth: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEVICES: Device[] = [
|
||||||
|
{
|
||||||
|
id: "kindle",
|
||||||
|
label: "Kindle Paperwhite",
|
||||||
|
kind: "eink",
|
||||||
|
bg: "#f4f3ee",
|
||||||
|
ink: "#2b2b2b",
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
marginX: 34,
|
||||||
|
marginY: 40,
|
||||||
|
aspect: 1240 / 1680,
|
||||||
|
bezel: "#1c1c1c",
|
||||||
|
bezelWidth: 18,
|
||||||
|
radius: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kobo",
|
||||||
|
label: "Kobo",
|
||||||
|
kind: "eink",
|
||||||
|
bg: "#f6f5f0",
|
||||||
|
ink: "#262626",
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
marginX: 34,
|
||||||
|
marginY: 40,
|
||||||
|
aspect: 1264 / 1680,
|
||||||
|
bezel: "#2a2a2a",
|
||||||
|
bezelWidth: 16,
|
||||||
|
radius: 16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ipad",
|
||||||
|
label: "iPad",
|
||||||
|
kind: "tablet",
|
||||||
|
bg: "#fbfaf7",
|
||||||
|
ink: "#1d1d1f",
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 1.65,
|
||||||
|
marginX: 56,
|
||||||
|
marginY: 56,
|
||||||
|
aspect: 1640 / 2360,
|
||||||
|
bezel: "#3a3a3c",
|
||||||
|
bezelWidth: 20,
|
||||||
|
radius: 28,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iphone",
|
||||||
|
label: "iPhone",
|
||||||
|
kind: "phone",
|
||||||
|
bg: "#ffffff",
|
||||||
|
ink: "#1d1d1f",
|
||||||
|
fontSize: 17,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
marginX: 26,
|
||||||
|
marginY: 30,
|
||||||
|
aspect: 1179 / 2556,
|
||||||
|
bezel: "#1a1a1c",
|
||||||
|
bezelWidth: 12,
|
||||||
|
radius: 44,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "android",
|
||||||
|
label: "Android phone",
|
||||||
|
kind: "phone",
|
||||||
|
bg: "#ffffff",
|
||||||
|
ink: "#202124",
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
marginX: 24,
|
||||||
|
marginY: 28,
|
||||||
|
aspect: 1080 / 2400,
|
||||||
|
bezel: "#0f0f0f",
|
||||||
|
bezelWidth: 11,
|
||||||
|
radius: 36,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEVICE_MAP = new Map<string, Device>(DEVICES.map((d) => [d.id, d]));
|
||||||
|
|
||||||
|
const KEY = "margin-preview-mode";
|
||||||
|
|
||||||
|
export function findDevice(id: string): Device | undefined {
|
||||||
|
return DEVICE_MAP.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialPreviewMode(): PreviewMode {
|
||||||
|
const saved = localStorage.getItem(KEY);
|
||||||
|
if (saved === "print" || (saved && DEVICE_MAP.has(saved))) return saved as PreviewMode;
|
||||||
|
return "print";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistPreviewMode(mode: PreviewMode) {
|
||||||
|
localStorage.setItem(KEY, mode);
|
||||||
|
}
|
||||||
@@ -2,34 +2,88 @@ import { useEffect, useRef } from "react";
|
|||||||
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
|
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
|
||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { editorExtensions } from "./extensions";
|
import { editorExtensions } from "./extensions";
|
||||||
|
import { loadPosition, savePosition, type ChapterPosition } from "./positions";
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
|
bookId: string;
|
||||||
chapterId: string;
|
chapterId: string;
|
||||||
content: JSONContent;
|
content: JSONContent;
|
||||||
onChange: (content: JSONContent) => void;
|
onChange: (content: JSONContent) => void;
|
||||||
onReady: (editor: TiptapEditor | null) => void;
|
onReady: (editor: TiptapEditor | null) => void;
|
||||||
|
onContentError: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Editor({ chapterId, content, onChange, onReady }: EditorProps) {
|
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
|
const onContentErrorRef = useRef(onContentError);
|
||||||
|
onContentErrorRef.current = onContentError;
|
||||||
|
|
||||||
|
const latest = useRef<ChapterPosition | null>(null);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: editorExtensions,
|
extensions: editorExtensions,
|
||||||
content,
|
content,
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
|
enableContentCheck: true,
|
||||||
editorProps: { attributes: { class: "prose" } },
|
editorProps: { attributes: { class: "prose" } },
|
||||||
|
onContentError: ({ error }) => onContentErrorRef.current(error),
|
||||||
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
|
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onReady(editor);
|
onReady(editor);
|
||||||
|
return () => onReady(null);
|
||||||
}, [editor, onReady]);
|
}, [editor, onReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor) editor.commands.setContent(content, { emitUpdate: false });
|
if (!editor) return;
|
||||||
|
let active = true;
|
||||||
|
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
||||||
|
|
||||||
|
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;
|
||||||
|
editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run();
|
||||||
|
|
||||||
|
if (scroller) {
|
||||||
|
const top = saved?.scroll ?? 0;
|
||||||
|
const restore = () => {
|
||||||
|
if (active) scroller.scrollTop = top;
|
||||||
|
};
|
||||||
|
restore();
|
||||||
|
requestAnimationFrame(restore);
|
||||||
|
document.fonts?.ready.then(restore).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chapterId, editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const capture = () => {
|
||||||
|
const { from, to } = editor.state.selection;
|
||||||
|
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 };
|
||||||
|
};
|
||||||
|
const persist = () => {
|
||||||
|
capture();
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400);
|
||||||
|
};
|
||||||
|
editor.on("selectionUpdate", persist);
|
||||||
|
scroller?.addEventListener("scroll", persist, { passive: true });
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
editor.off("selectionUpdate", persist);
|
||||||
|
scroller?.removeEventListener("scroll", persist);
|
||||||
|
if (latest.current) savePosition(bookId, chapterId, latest.current);
|
||||||
|
};
|
||||||
|
}, [editor, bookId, chapterId]);
|
||||||
|
|
||||||
return <EditorContent editor={editor} className="editor-host" />;
|
return <EditorContent editor={editor} className="editor-host" />;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export interface ChapterPosition {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
scroll: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = "margin-positions";
|
||||||
|
|
||||||
|
type Store = Record<string, Record<string, ChapterPosition>>;
|
||||||
|
|
||||||
|
function readAll(): Store {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(KEY) || "{}") as Store;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(key: string, value: unknown): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPosition(bookId: string, chapterId: string): ChapterPosition | null {
|
||||||
|
return readAll()[bookId]?.[chapterId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void {
|
||||||
|
const all = readAll();
|
||||||
|
(all[bookId] ||= {})[chapterId] = position;
|
||||||
|
write(KEY, all);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_KEY = "margin-active-chapter";
|
||||||
|
|
||||||
|
function readActive(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(ACTIVE_KEY) || "{}") as Record<string, string>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadActiveChapter(bookId: string): string | null {
|
||||||
|
return readActive()[bookId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveActiveChapter(bookId: string, chapterId: string): void {
|
||||||
|
const all = readActive();
|
||||||
|
all[bookId] = chapterId;
|
||||||
|
write(ACTIVE_KEY, all);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPositions(bookId: string): void {
|
||||||
|
const positions = readAll();
|
||||||
|
const active = readActive();
|
||||||
|
delete positions[bookId];
|
||||||
|
delete active[bookId];
|
||||||
|
write(KEY, positions);
|
||||||
|
write(ACTIVE_KEY, active);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
||||||
|
import { fontStack, fontsUsed } from "../model/fonts";
|
||||||
|
|
||||||
export interface EpubFile {
|
export interface EpubFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -25,7 +26,7 @@ function slug(text: string): string {
|
|||||||
|
|
||||||
function imageExtension(dataUrl: string): string {
|
function imageExtension(dataUrl: string): string {
|
||||||
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
|
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
|
||||||
const kind = (match?.[1] ?? "png").toLowerCase();
|
const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
|
||||||
return kind === "jpeg" ? "jpg" : kind;
|
return kind === "jpeg" ? "jpg" : kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,12 +39,65 @@ function mediaType(path: string): string {
|
|||||||
return "application/octet-stream";
|
return "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FontAssets {
|
||||||
|
files: EpubFile[];
|
||||||
|
faces: string;
|
||||||
|
manifest: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64FromBuffer(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
const chunk = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunk) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFontBase64(file: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/fonts/${file}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return base64FromBuffer(await res.arrayBuffer());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFontAssets(book: Book): Promise<FontAssets> {
|
||||||
|
const used = fontsUsed(book.settings.fonts).bundled;
|
||||||
|
const files: EpubFile[] = [];
|
||||||
|
const faces: string[] = [];
|
||||||
|
const manifest: string[] = [];
|
||||||
|
let n = 0;
|
||||||
|
for (const font of used) {
|
||||||
|
const variants = [{ file: font.regular, style: "normal" }];
|
||||||
|
if (font.italic) variants.push({ file: font.italic, style: "italic" });
|
||||||
|
for (const variant of variants) {
|
||||||
|
const data = await fetchFontBase64(variant.file);
|
||||||
|
if (!data) continue;
|
||||||
|
const href = `fonts/${variant.file}`;
|
||||||
|
const id = `font-${++n}`;
|
||||||
|
files.push({ path: `OEBPS/${href}`, data, encoding: "base64" });
|
||||||
|
manifest.push(`<item id="${id}" href="${attr(href)}" media-type="font/ttf"/>`);
|
||||||
|
faces.push(
|
||||||
|
`@font-face {\n font-family: "${font.family}";\n font-style: ${variant.style};\n font-weight: ${font.weight};\n src: url("${href}");\n}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { files, faces: faces.join("\n\n"), manifest };
|
||||||
|
}
|
||||||
|
|
||||||
function inline(node: JSONContent): string {
|
function inline(node: JSONContent): string {
|
||||||
if (node.type === "text") {
|
if (node.type === "text") {
|
||||||
let text = esc(node.text ?? "");
|
let text = esc(node.text ?? "");
|
||||||
const marks = node.marks ?? [];
|
const marks = node.marks ?? [];
|
||||||
|
if (marks.some((m) => m.type === "code")) text = `<code>${text}</code>`;
|
||||||
if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`;
|
if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`;
|
||||||
if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`;
|
if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`;
|
||||||
|
if (marks.some((m) => m.type === "strike")) text = `<s>${text}</s>`;
|
||||||
|
if (marks.some((m) => m.type === "underline")) text = `<u>${text}</u>`;
|
||||||
const href = marks.find((m) => m.type === "link")?.attrs?.href;
|
const href = marks.find((m) => m.type === "link")?.attrs?.href;
|
||||||
if (href) text = `<a href="${attr(href)}">${text}</a>`;
|
if (href) text = `<a href="${attr(href)}">${text}</a>`;
|
||||||
return text;
|
return text;
|
||||||
@@ -56,11 +110,11 @@ function inlines(content: JSONContent[] = []): string {
|
|||||||
return content.map(inline).join("");
|
return content.map(inline).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function listItem(item: JSONContent): string {
|
function listItem(item: JSONContent, paths: Map<string, string>): string {
|
||||||
const inner = (item.content ?? [])
|
const inner = (item.content ?? [])
|
||||||
.filter((c) => c.type === "paragraph")
|
.map((child) => block(child, paths))
|
||||||
.map((p) => inlines(p.content))
|
.filter((s) => s.length > 0)
|
||||||
.join(" ");
|
.join("");
|
||||||
return `<li>${inner}</li>`;
|
return `<li>${inner}</li>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +146,9 @@ function block(node: JSONContent, paths: Map<string, string>): string {
|
|||||||
case "blockquote":
|
case "blockquote":
|
||||||
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
|
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
|
||||||
case "bulletList":
|
case "bulletList":
|
||||||
return `<ul>${(node.content ?? []).map(listItem).join("")}</ul>`;
|
return `<ul>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ul>`;
|
||||||
case "orderedList":
|
case "orderedList":
|
||||||
return `<ol>${(node.content ?? []).map(listItem).join("")}</ol>`;
|
return `<ol>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ol>`;
|
||||||
case "horizontalRule":
|
case "horizontalRule":
|
||||||
return `<hr class="scene-break"/>`;
|
return `<hr class="scene-break"/>`;
|
||||||
case "figure":
|
case "figure":
|
||||||
@@ -237,6 +291,7 @@ function contentOpf(
|
|||||||
chapterIds: string[],
|
chapterIds: string[],
|
||||||
assetPaths: string[],
|
assetPaths: string[],
|
||||||
cover: { href: string; mediaType: string },
|
cover: { href: string; mediaType: string },
|
||||||
|
fontManifest: string[],
|
||||||
): string {
|
): string {
|
||||||
const meta = book.metadata;
|
const meta = book.metadata;
|
||||||
const manifest: string[] = [
|
const manifest: string[] = [
|
||||||
@@ -244,8 +299,7 @@ function contentOpf(
|
|||||||
`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`,
|
`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`,
|
||||||
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
|
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
|
||||||
`<item id="css" href="style.css" media-type="text/css"/>`,
|
`<item id="css" href="style.css" media-type="text/css"/>`,
|
||||||
`<item id="font-regular" href="fonts/Literata-VF.ttf" media-type="font/ttf"/>`,
|
...fontManifest,
|
||||||
`<item id="font-italic" href="fonts/Literata-Italic-VF.ttf" media-type="font/ttf"/>`,
|
|
||||||
];
|
];
|
||||||
chapterIds.forEach((_, i) => {
|
chapterIds.forEach((_, i) => {
|
||||||
manifest.push(
|
manifest.push(
|
||||||
@@ -331,23 +385,9 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function styleCss(): string {
|
function styleCss(book: Book, faces: string): string {
|
||||||
return `@font-face {
|
return `${faces ? faces + "\n\n" : ""}html {
|
||||||
font-family: "Literata";
|
font-family: ${fontStack(book.settings.fonts.body)};
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300 700;
|
|
||||||
src: url("fonts/Literata-VF.ttf");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Literata";
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 300 700;
|
|
||||||
src: url("fonts/Literata-Italic-VF.ttf");
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-family: "Literata", Georgia, serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover {
|
.cover {
|
||||||
@@ -380,6 +420,7 @@ p[data-indent="true"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3 {
|
h1, h2, h3 {
|
||||||
|
font-family: ${fontStack(book.settings.fonts.heading)};
|
||||||
hyphens: none;
|
hyphens: none;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -406,6 +447,11 @@ h2, h3 {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
margin: 1.2em 1.6em;
|
margin: 1.2em 1.6em;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -469,7 +515,7 @@ figure.placement-float-top {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bookToEpub(book: Book): EpubFile[] {
|
export function bookToEpub(book: Book, fontAssets: FontAssets): EpubFile[] {
|
||||||
const { assets, paths } = extractFigures(book);
|
const { assets, paths } = extractFigures(book);
|
||||||
const cover = buildCover(book);
|
const cover = buildCover(book);
|
||||||
const chapterIds = book.chapters.map((c) => c.id);
|
const chapterIds = book.chapters.map((c) => c.id);
|
||||||
@@ -480,12 +526,12 @@ export function bookToEpub(book: Book): EpubFile[] {
|
|||||||
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
||||||
{
|
{
|
||||||
path: "OEBPS/content.opf",
|
path: "OEBPS/content.opf",
|
||||||
data: contentOpf(book, chapterIds, assetPaths, cover),
|
data: contentOpf(book, chapterIds, assetPaths, cover, fontAssets.manifest),
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
},
|
},
|
||||||
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
|
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
|
||||||
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
||||||
{ path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" },
|
{ path: "OEBPS/style.css", data: styleCss(book, fontAssets.faces), encoding: "utf8" },
|
||||||
cover.asset,
|
cover.asset,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -497,12 +543,13 @@ export function bookToEpub(book: Book): EpubFile[] {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
files.push(...assets);
|
files.push(...assets, ...fontAssets.files);
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildEpub(book: Book): Promise<Uint8Array> {
|
export async function buildEpub(book: Book): Promise<Uint8Array> {
|
||||||
const files = bookToEpub(book);
|
const fontAssets = await loadFontAssets(book);
|
||||||
|
const files = bookToEpub(book, fontAssets);
|
||||||
const buf = await invoke<ArrayBuffer>("package_epub", { files });
|
const buf = await invoke<ArrayBuffer>("package_epub", { files });
|
||||||
return new Uint8Array(buf);
|
return new Uint8Array(buf);
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,8 @@ const PDF = [{ name: "PDF", extensions: ["pdf"] }];
|
|||||||
const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
|
const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
|
||||||
|
|
||||||
export async function exportPdf(book: Book): Promise<void> {
|
export async function exportPdf(book: Book): Promise<void> {
|
||||||
const { source, images } = bookToPdfInputs(book);
|
const { source, images, fonts } = bookToPdfInputs(book);
|
||||||
const bytes = await compilePdf(source, images);
|
const bytes = await compilePdf(source, images, true, fonts);
|
||||||
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
|
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Book } from "../model/book";
|
import type { Book } from "../model/book";
|
||||||
import { exportEpub, exportPdf } from "./exporters";
|
import { exportEpub, exportPdf } from "./exporters";
|
||||||
|
import { unsupportedScripts } from "./typst";
|
||||||
import { useBook } from "../store/useBook";
|
import { useBook } from "../store/useBook";
|
||||||
import { isDesktop } from "../ipc";
|
import { isDesktop } from "../ipc";
|
||||||
|
|
||||||
@@ -29,6 +30,11 @@ export async function runExport(format: "pdf" | "epub"): Promise<void> {
|
|||||||
await nextPaint();
|
await nextPaint();
|
||||||
try {
|
try {
|
||||||
await run(book);
|
await run(book);
|
||||||
|
if (format === "pdf") {
|
||||||
|
const scripts = unsupportedScripts(book);
|
||||||
|
if (scripts.length)
|
||||||
|
setNotice(`Some ${scripts.join(", ")} characters may not render — the PDF fonts cover Latin scripts only.`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setNotice(`${label} export failed: ${e}`);
|
setNotice(`${label} export failed: ${e}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
||||||
|
import { fontFamilyName, fontsUsed } from "../model/fonts";
|
||||||
import type { ImageInput } from "../ipc";
|
import type { ImageInput } from "../ipc";
|
||||||
|
|
||||||
|
export interface PdfFonts {
|
||||||
|
bundled: string[];
|
||||||
|
system: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pdfFonts(book: Book): PdfFonts {
|
||||||
|
const used = fontsUsed(book.settings.fonts);
|
||||||
|
return { bundled: used.bundled.map((f) => f.id), system: used.system };
|
||||||
|
}
|
||||||
|
|
||||||
const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
||||||
"6x9": { w: "6in", h: "9in" },
|
"6x9": { w: "6in", h: "9in" },
|
||||||
"5.5x8.5": { w: "5.5in", h: "8.5in" },
|
"5.5x8.5": { w: "5.5in", h: "8.5in" },
|
||||||
@@ -10,27 +21,32 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g;
|
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g;
|
||||||
|
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
|
||||||
|
|
||||||
function esc(text: string): string {
|
function esc(text: string): string {
|
||||||
return text.replace(INLINE_SPECIAL, (m) => "\\" + m);
|
return text.replace(LINE_SEPARATORS, " ").replace(INLINE_SPECIAL, (m) => "\\" + m);
|
||||||
}
|
}
|
||||||
|
|
||||||
function guardLineStart(line: string): string {
|
function guardLineStart(text: string): string {
|
||||||
if (/^[=\-+/]/.test(line)) return "\\" + line;
|
if (/^[=\-+/]/.test(text)) return "\\" + text;
|
||||||
if (/^\d+\./.test(line)) return line.replace(".", "\\.");
|
if (/^\d+\./.test(text)) return text.replace(".", "\\.");
|
||||||
return line;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function str(value: string): string {
|
function str(value: string): string {
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function inline(node: JSONContent): string {
|
function inline(node: JSONContent, atLineStart: boolean): string {
|
||||||
if (node.type === "text") {
|
if (node.type === "text") {
|
||||||
let text = esc(node.text ?? "");
|
|
||||||
const marks = node.marks ?? [];
|
const marks = node.marks ?? [];
|
||||||
|
if (marks.some((m) => m.type === "code")) return `#raw(${str(node.text ?? "")})`;
|
||||||
|
let text = esc(node.text ?? "");
|
||||||
|
if (atLineStart) text = guardLineStart(text);
|
||||||
if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`;
|
if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`;
|
||||||
if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`;
|
if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`;
|
||||||
|
if (marks.some((m) => m.type === "strike")) text = `#strike[${text}]`;
|
||||||
|
if (marks.some((m) => m.type === "underline")) text = `#underline[${text}]`;
|
||||||
const href = marks.find((m) => m.type === "link")?.attrs?.href;
|
const href = marks.find((m) => m.type === "link")?.attrs?.href;
|
||||||
if (href) text = `#link(${str(href)})[${text}]`;
|
if (href) text = `#link(${str(href)})[${text}]`;
|
||||||
return text;
|
return text;
|
||||||
@@ -39,15 +55,15 @@ function inline(node: JSONContent): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function inlines(content: JSONContent[] = []): string {
|
function inlines(content: JSONContent[] = [], atLineStart = false): string {
|
||||||
return content.map(inline).join("");
|
return content.map((node, i) => inline(node, atLineStart && i === 0)).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function listItem(item: JSONContent): string {
|
function listItem(item: JSONContent, paths: Map<string, string>): string {
|
||||||
return (item.content ?? [])
|
const parts = (item.content ?? [])
|
||||||
.filter((c) => c.type === "paragraph")
|
.map((child) => block(child, paths))
|
||||||
.map((p) => inlines(p.content))
|
.filter((s) => s.length > 0);
|
||||||
.join(" ");
|
return `[${parts.join("\n\n")}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function figure(node: JSONContent, paths: Map<string, string>): string {
|
function figure(node: JSONContent, paths: Map<string, string>): string {
|
||||||
@@ -59,7 +75,7 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
|
|||||||
}
|
}
|
||||||
const placed = placement as FigurePlacement;
|
const placed = placement as FigurePlacement;
|
||||||
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
|
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
|
||||||
const caption = node.attrs?.caption ? `, caption: [${esc(node.attrs.caption)}]` : "";
|
const caption = node.attrs?.caption ? `, caption: [${guardLineStart(esc(node.attrs.caption))}]` : "";
|
||||||
const float = placement === "float-top" ? ", placement: top" : "";
|
const float = placement === "float-top" ? ", placement: top" : "";
|
||||||
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
|
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
|
||||||
}
|
}
|
||||||
@@ -67,18 +83,18 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
|
|||||||
function block(node: JSONContent, paths: Map<string, string>): string {
|
function block(node: JSONContent, paths: Map<string, string>): string {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case "paragraph": {
|
case "paragraph": {
|
||||||
const body = guardLineStart(inlines(node.content));
|
const body = inlines(node.content, true);
|
||||||
if (!body.trim()) return "~";
|
if (!body.trim()) return "~";
|
||||||
return node.attrs?.indent ? `#h(1.3em)${body}` : body;
|
return node.attrs?.indent ? `#h(1.3em)${body}` : body;
|
||||||
}
|
}
|
||||||
case "heading":
|
case "heading":
|
||||||
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content)}]`;
|
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`;
|
||||||
case "blockquote":
|
case "blockquote":
|
||||||
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
|
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
|
||||||
case "bulletList":
|
case "bulletList":
|
||||||
return (node.content ?? []).map((li) => `- ${listItem(li)}`).join("\n");
|
return `#list(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
|
||||||
case "orderedList":
|
case "orderedList":
|
||||||
return (node.content ?? []).map((li) => `+ ${listItem(li)}`).join("\n");
|
return `#enum(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
|
||||||
case "horizontalRule":
|
case "horizontalRule":
|
||||||
return "#scenebreak";
|
return "#scenebreak";
|
||||||
case "figure":
|
case "figure":
|
||||||
@@ -99,6 +115,8 @@ function preamble(book: Book): string {
|
|||||||
const trim = TRIM[book.settings.trim];
|
const trim = TRIM[book.settings.trim];
|
||||||
const meta = book.metadata;
|
const meta = book.metadata;
|
||||||
const bleed = book.settings.bleed ? "0.125in" : "0in";
|
const bleed = book.settings.bleed ? "0.125in" : "0in";
|
||||||
|
const body = fontFamilyName(book.settings.fonts.body);
|
||||||
|
const heading = fontFamilyName(book.settings.fonts.heading);
|
||||||
return `#set document(title: ${str(meta.title || "Untitled")}, author: ${str(meta.author)})
|
return `#set document(title: ${str(meta.title || "Untitled")}, author: ${str(meta.author)})
|
||||||
#set page(
|
#set page(
|
||||||
width: ${trim.w} + ${bleed},
|
width: ${trim.w} + ${bleed},
|
||||||
@@ -106,9 +124,9 @@ function preamble(book: Book): string {
|
|||||||
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
|
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
|
||||||
binding: left,
|
binding: left,
|
||||||
)
|
)
|
||||||
#set text(font: "Literata", size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
|
#set text(font: ${str(body)}, size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
|
||||||
#set par(justify: true, leading: 0.72em, spacing: 0.72em)
|
#set par(justify: true, leading: 0.72em, spacing: 0.72em)
|
||||||
#show heading: set text(font: "Literata", weight: "medium")
|
#show heading: set text(font: ${str(heading)}, weight: "medium")
|
||||||
#show heading.where(level: 1): set text(size: 22pt)
|
#show heading.where(level: 1): set text(size: 22pt)
|
||||||
#show heading.where(level: 2): set block(above: 1.4em, below: 0.6em)
|
#show heading.where(level: 2): set block(above: 1.4em, below: 0.6em)
|
||||||
#show heading.where(level: 3): set block(above: 1.2em, below: 0.5em)
|
#show heading.where(level: 3): set block(above: 1.2em, below: 0.5em)
|
||||||
@@ -120,10 +138,10 @@ function preamble(book: Book): string {
|
|||||||
#let titlepage(title, subtitle, author) = {
|
#let titlepage(title, subtitle, author) = {
|
||||||
v(2.4in)
|
v(2.4in)
|
||||||
align(center)[
|
align(center)[
|
||||||
#text(font: "Literata", size: 30pt, weight: "medium")[#title]
|
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[#title]
|
||||||
#if subtitle != "" {
|
#if subtitle != "" {
|
||||||
v(0.6em)
|
v(0.6em)
|
||||||
text(font: "Literata", size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
|
text(font: ${str(body)}, size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
|
||||||
}
|
}
|
||||||
#if author != "" {
|
#if author != "" {
|
||||||
v(1.7em)
|
v(1.7em)
|
||||||
@@ -159,7 +177,7 @@ function preamble(book: Book): string {
|
|||||||
set par(justify: false, first-line-indent: 0pt)
|
set par(justify: false, first-line-indent: 0pt)
|
||||||
block(below: 2em)[
|
block(below: 2em)[
|
||||||
#show heading: set block(above: 0pt, below: 0pt)
|
#show heading: set block(above: 0pt, below: 0pt)
|
||||||
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: "Literata", size: 24pt, weight: "medium")[Contents]]
|
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: ${str(heading)}, size: 24pt, weight: "medium")[Contents]]
|
||||||
]
|
]
|
||||||
context {
|
context {
|
||||||
let items = query(<chap>).filter(it => it.value.toc != false)
|
let items = query(<chap>).filter(it => it.value.toc != false)
|
||||||
@@ -188,20 +206,22 @@ function preamble(book: Book): string {
|
|||||||
|
|
||||||
function imageExtension(dataUrl: string): string {
|
function imageExtension(dataUrl: string): string {
|
||||||
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
|
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
|
||||||
const kind = (match?.[1] ?? "png").toLowerCase();
|
const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
|
||||||
return kind === "jpeg" ? "jpg" : kind;
|
return kind === "jpeg" ? "jpg" : kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
function coverBlock(book: Book, coverPath?: string): string {
|
function coverBlock(book: Book, coverPath?: string): string {
|
||||||
const c = book.cover;
|
const c = book.cover;
|
||||||
const meta = book.metadata;
|
const meta = book.metadata;
|
||||||
|
const body = fontFamilyName(book.settings.fonts.body);
|
||||||
|
const heading = fontFamilyName(book.settings.fonts.heading);
|
||||||
|
|
||||||
if (c.kind === "image" && coverPath) {
|
if (c.kind === "image" && coverPath) {
|
||||||
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
|
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subtitle = meta.subtitle
|
const subtitle = meta.subtitle
|
||||||
? `#v(0.55em)\n #text(font: "Literata", size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
? `#v(0.55em)\n #text(font: ${str(body)}, size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
||||||
: "";
|
: "";
|
||||||
const author = meta.author
|
const author = meta.author
|
||||||
? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
|
? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
|
||||||
@@ -211,7 +231,7 @@ function coverBlock(book: Book, coverPath?: string): string {
|
|||||||
#set align(center)
|
#set align(center)
|
||||||
#set text(fill: rgb(${str(c.ink)}))
|
#set text(fill: rgb(${str(c.ink)}))
|
||||||
#v(1fr)
|
#v(1fr)
|
||||||
#text(font: "Literata", size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
||||||
#v(0.5em)
|
#v(0.5em)
|
||||||
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
|
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
|
||||||
${subtitle}
|
${subtitle}
|
||||||
@@ -251,6 +271,34 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map<st
|
|||||||
return collectImages(book.chapters.map((chapter) => chapter.content));
|
return collectImages(book.chapters.map((chapter) => chapter.content));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCRIPT_RANGES: { label: string; test: RegExp }[] = [
|
||||||
|
{ label: "CJK", test: /[-鿿가-豈-]/u },
|
||||||
|
{ label: "Arabic", test: /[-ۿݐ-ݿ]/u },
|
||||||
|
{ label: "Hebrew", test: /[-]/u },
|
||||||
|
{ label: "Devanagari", test: /[ऀ-ॿ]/u },
|
||||||
|
{ label: "Thai", test: /[-]/u },
|
||||||
|
{ label: "emoji", test: /[☀-➿]|[\u{1f000}-\u{1faff}]/u },
|
||||||
|
];
|
||||||
|
|
||||||
|
function collectText(book: Book): string {
|
||||||
|
const parts = [book.metadata.title, book.metadata.subtitle, book.metadata.author];
|
||||||
|
const visit = (node: JSONContent) => {
|
||||||
|
if (node.type === "text" && node.text) parts.push(node.text);
|
||||||
|
if (node.attrs?.caption) parts.push(String(node.attrs.caption));
|
||||||
|
(node.content ?? []).forEach(visit);
|
||||||
|
};
|
||||||
|
book.chapters.forEach((chapter) => {
|
||||||
|
parts.push(chapter.title);
|
||||||
|
visit(chapter.content);
|
||||||
|
});
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsupportedScripts(book: Book): string[] {
|
||||||
|
const text = collectText(book);
|
||||||
|
return SCRIPT_RANGES.filter((s) => s.test.test(text)).map((s) => s.label);
|
||||||
|
}
|
||||||
|
|
||||||
function cleanTitle(title: string): string {
|
function cleanTitle(title: string): string {
|
||||||
return (title || "")
|
return (title || "")
|
||||||
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
|
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
|
||||||
@@ -301,16 +349,16 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
|
|||||||
return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`;
|
return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||||
const { images, paths } = extractImages(book);
|
const { images, paths } = extractImages(book);
|
||||||
const coverPath = pushCoverImage(book, images);
|
const coverPath = pushCoverImage(book, images);
|
||||||
return { source: bookToTypst(book, paths, coverPath), images };
|
return { source: bookToTypst(book, paths, coverPath), images, fonts: pdfFonts(book) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||||
const images: ImageInput[] = [];
|
const images: ImageInput[] = [];
|
||||||
const coverPath = pushCoverImage(book, images);
|
const coverPath = pushCoverImage(book, images);
|
||||||
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images };
|
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images, fonts: pdfFonts(book) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
||||||
@@ -325,8 +373,8 @@ ${chapterBody(book.chapters[index].content, paths)}
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[] } {
|
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||||
const chapter = book.chapters[index];
|
const chapter = book.chapters[index];
|
||||||
const { images, paths } = collectImages([chapter.content]);
|
const { images, paths } = collectImages([chapter.content]);
|
||||||
return { source: chapterToTypst(book, index, paths), images };
|
return { source: chapterToTypst(book, index, paths), images, fonts: pdfFonts(book) };
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { type Book, type BookMetadata, type Chapter, createCover } from "../model/book";
|
import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book";
|
||||||
|
import { DEFAULT_FONTS } from "../model/fonts";
|
||||||
|
|
||||||
export interface RawFile {
|
export interface RawFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -9,7 +10,7 @@ export interface RawFile {
|
|||||||
encoding: "utf8" | "base64";
|
encoding: "utf8" | "base64";
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mark = { type: string };
|
type Mark = { type: string; attrs?: Record<string, unknown> };
|
||||||
|
|
||||||
const BLOCK_TAGS = new Set([
|
const BLOCK_TAGS = new Set([
|
||||||
"p", "div", "section", "article", "main", "header", "footer", "aside",
|
"p", "div", "section", "article", "main", "header", "footer", "aside",
|
||||||
@@ -81,8 +82,8 @@ function hasMark(marks: Mark[], type: string): boolean {
|
|||||||
return marks.some((m) => m.type === type);
|
return marks.some((m) => m.type === type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMark(marks: Mark[], type: string): Mark[] {
|
function addMark(marks: Mark[], type: string, attrs?: Record<string, unknown>): Mark[] {
|
||||||
return hasMark(marks, type) ? marks : [...marks, { type }];
|
return hasMark(marks, type) ? marks : [...marks, attrs ? { type, attrs } : { type }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
|
function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
|
||||||
@@ -102,6 +103,15 @@ function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
|
|||||||
collectInline(el, addMark(marks, "bold"), out);
|
collectInline(el, addMark(marks, "bold"), out);
|
||||||
} else if (tag === "em" || tag === "i") {
|
} else if (tag === "em" || tag === "i") {
|
||||||
collectInline(el, addMark(marks, "italic"), out);
|
collectInline(el, addMark(marks, "italic"), out);
|
||||||
|
} else if (tag === "u") {
|
||||||
|
collectInline(el, addMark(marks, "underline"), out);
|
||||||
|
} else if (tag === "s" || tag === "strike" || tag === "del") {
|
||||||
|
collectInline(el, addMark(marks, "strike"), out);
|
||||||
|
} else if (tag === "code") {
|
||||||
|
collectInline(el, addMark(marks, "code"), out);
|
||||||
|
} else if (tag === "a") {
|
||||||
|
const href = el.getAttribute("href");
|
||||||
|
collectInline(el, href ? addMark(marks, "link", { href }) : marks, out);
|
||||||
} else if (!BLOCK_TAGS.has(tag)) {
|
} else if (!BLOCK_TAGS.has(tag)) {
|
||||||
collectInline(el, marks, out);
|
collectInline(el, marks, out);
|
||||||
}
|
}
|
||||||
@@ -137,13 +147,23 @@ function meaningful(inline: JSONContent[]): boolean {
|
|||||||
|
|
||||||
function imgFigure(img: Element, caption = ""): JSONContent[] {
|
function imgFigure(img: Element, caption = ""): JSONContent[] {
|
||||||
const src = img.getAttribute("src");
|
const src = img.getAttribute("src");
|
||||||
if (!src || !src.startsWith("data:")) return [];
|
if (src && src.startsWith("data:")) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: "figure",
|
type: "figure",
|
||||||
attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" },
|
attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
const alt = (img.getAttribute("alt") ?? "").trim();
|
||||||
|
return alt ? [{ type: "paragraph", content: [{ type: "text", text: alt }] }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgFigure(el: Element): JSONContent[] {
|
||||||
|
const markup = el.outerHTML;
|
||||||
|
if (!markup) return [];
|
||||||
|
const src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(markup)))}`;
|
||||||
|
return [{ type: "figure", attrs: { src, alt: "", caption: "", placement: "full-width" } }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function figureFrom(el: Element): JSONContent[] {
|
function figureFrom(el: Element): JSONContent[] {
|
||||||
@@ -165,18 +185,35 @@ function hasBlockChild(el: Element): boolean {
|
|||||||
return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase()));
|
return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
function listItems(el: Element): JSONContent[] {
|
function listItems(el: Element, depth: number): JSONContent[] {
|
||||||
const items: JSONContent[] = [];
|
const items: JSONContent[] = [];
|
||||||
Array.from(el.children)
|
Array.from(el.children)
|
||||||
.filter((c) => c.localName.toLowerCase() === "li")
|
.filter((c) => c.localName.toLowerCase() === "li")
|
||||||
.forEach((li) => {
|
.forEach((li) => {
|
||||||
const content = blocksFrom(li);
|
const content = blocksFrom(li, depth + 1);
|
||||||
items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] });
|
items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] });
|
||||||
});
|
});
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockFromElement(el: Element): JSONContent[] {
|
function tableBlocks(el: Element): JSONContent[] {
|
||||||
|
const out: JSONContent[] = [];
|
||||||
|
Array.from(el.querySelectorAll("tr")).forEach((tr) => {
|
||||||
|
const content: JSONContent[] = [];
|
||||||
|
Array.from(tr.children)
|
||||||
|
.filter((c) => c.localName.toLowerCase() === "td" || c.localName.toLowerCase() === "th")
|
||||||
|
.forEach((cell) => {
|
||||||
|
const inline = inlineOf(cell);
|
||||||
|
if (!meaningful(inline)) return;
|
||||||
|
if (content.length) content.push({ type: "text", text: " · " });
|
||||||
|
content.push(...inline);
|
||||||
|
});
|
||||||
|
if (meaningful(content)) out.push({ type: "paragraph", content });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockFromElement(el: Element, depth: number): JSONContent[] {
|
||||||
const tag = el.localName.toLowerCase();
|
const tag = el.localName.toLowerCase();
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
case "p":
|
case "p":
|
||||||
@@ -192,15 +229,15 @@ function blockFromElement(el: Element): JSONContent[] {
|
|||||||
return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : [];
|
return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : [];
|
||||||
}
|
}
|
||||||
case "blockquote": {
|
case "blockquote": {
|
||||||
const inner = blocksFrom(el);
|
const inner = blocksFrom(el, depth + 1);
|
||||||
return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }];
|
return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }];
|
||||||
}
|
}
|
||||||
case "ul": {
|
case "ul": {
|
||||||
const items = listItems(el);
|
const items = listItems(el, depth);
|
||||||
return items.length ? [{ type: "bulletList", content: items }] : [];
|
return items.length ? [{ type: "bulletList", content: items }] : [];
|
||||||
}
|
}
|
||||||
case "ol": {
|
case "ol": {
|
||||||
const items = listItems(el);
|
const items = listItems(el, depth);
|
||||||
return items.length ? [{ type: "orderedList", content: items }] : [];
|
return items.length ? [{ type: "orderedList", content: items }] : [];
|
||||||
}
|
}
|
||||||
case "hr":
|
case "hr":
|
||||||
@@ -209,22 +246,27 @@ function blockFromElement(el: Element): JSONContent[] {
|
|||||||
return figureFrom(el);
|
return figureFrom(el);
|
||||||
case "img":
|
case "img":
|
||||||
return imgFigure(el);
|
return imgFigure(el);
|
||||||
|
case "svg":
|
||||||
|
return svgFigure(el);
|
||||||
|
case "table":
|
||||||
|
return tableBlocks(el);
|
||||||
case "script":
|
case "script":
|
||||||
case "style":
|
case "style":
|
||||||
return [];
|
return [];
|
||||||
default:
|
default:
|
||||||
return hasBlockChild(el) ? blocksFrom(el) : paragraphAndFigures(el);
|
return hasBlockChild(el) ? blocksFrom(el, depth + 1) : paragraphAndFigures(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function blocksFrom(parent: Node): JSONContent[] {
|
function blocksFrom(parent: Node, depth = 0): JSONContent[] {
|
||||||
|
if (depth > 64) return [];
|
||||||
const out: JSONContent[] = [];
|
const out: JSONContent[] = [];
|
||||||
parent.childNodes.forEach((child) => {
|
parent.childNodes.forEach((child) => {
|
||||||
if (child.nodeType === 3) {
|
if (child.nodeType === 3) {
|
||||||
const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim();
|
const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim();
|
||||||
if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] });
|
if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] });
|
||||||
} else if (child.nodeType === 1) {
|
} else if (child.nodeType === 1) {
|
||||||
out.push(...blockFromElement(child as Element));
|
out.push(...blockFromElement(child as Element, depth));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return out;
|
return out;
|
||||||
@@ -234,6 +276,41 @@ function headingText(node: JSONContent): string {
|
|||||||
return (node.content ?? []).map((n) => n.text ?? "").join("").trim();
|
return (node.content ?? []).map((n) => n.text ?? "").join("").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isbnChecksum(s: string): boolean {
|
||||||
|
if (s.length === 10) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const v = s[i] === "X" ? 10 : Number(s[i]);
|
||||||
|
if (Number.isNaN(v) || (s[i] === "X" && i !== 9)) return false;
|
||||||
|
sum += v * (10 - i);
|
||||||
|
}
|
||||||
|
return sum % 11 === 0;
|
||||||
|
}
|
||||||
|
if (s.length === 13 && /^\d{13}$/.test(s)) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < 13; i++) sum += Number(s[i]) * (i % 2 === 0 ? 1 : 3);
|
||||||
|
return sum % 10 === 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findIsbn(opf: Document): string {
|
||||||
|
const ids = Array.from(opf.getElementsByTagName("*")).filter((el) => el.localName === "identifier");
|
||||||
|
const ranked = ids
|
||||||
|
.map((el) => ({
|
||||||
|
text: el.textContent ?? "",
|
||||||
|
tagged:
|
||||||
|
/isbn/i.test(el.getAttribute("opf:scheme") || el.getAttribute("scheme") || "") ||
|
||||||
|
/urn:isbn:/i.test(el.textContent ?? ""),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Number(b.tagged) - Number(a.tagged));
|
||||||
|
for (const { text } of ranked) {
|
||||||
|
const cleaned = text.replace(/[^0-9Xx]/g, "").toUpperCase();
|
||||||
|
if (isbnChecksum(cleaned)) return cleaned;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
interface ManifestItem {
|
interface ManifestItem {
|
||||||
href: string;
|
href: string;
|
||||||
mediaType: string;
|
mediaType: string;
|
||||||
@@ -317,6 +394,11 @@ function readNavTitles(
|
|||||||
return titles;
|
return titles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileToDataUri(file: RawFile, mime: string): string {
|
||||||
|
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
|
||||||
|
return `data:${mime};base64,${data}`;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveImages(
|
function resolveImages(
|
||||||
doc: Document,
|
doc: Document,
|
||||||
baseDir: string,
|
baseDir: string,
|
||||||
@@ -335,12 +417,29 @@ function resolveImages(
|
|||||||
img.removeAttribute("src");
|
img.removeAttribute("src");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mime = mimeByPath.get(resolved) || mimeFromExt(resolved);
|
img.setAttribute("src", fileToDataUri(file, mimeByPath.get(resolved) || mimeFromExt(resolved)));
|
||||||
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
|
|
||||||
img.setAttribute("src", `data:${mime};base64,${data}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findCover(
|
||||||
|
opf: Document,
|
||||||
|
manifest: Map<string, ManifestItem>,
|
||||||
|
byPath: Map<string, RawFile>,
|
||||||
|
): Cover {
|
||||||
|
let item = Array.from(manifest.values()).find((m) => m.properties.split(/\s+/).includes("cover-image"));
|
||||||
|
if (!item) {
|
||||||
|
const metaCover = Array.from(opf.getElementsByTagName("*")).find(
|
||||||
|
(el) => el.localName === "meta" && el.getAttribute("name") === "cover",
|
||||||
|
);
|
||||||
|
const id = metaCover?.getAttribute("content");
|
||||||
|
if (id) item = manifest.get(id);
|
||||||
|
}
|
||||||
|
if (!item) return createCover();
|
||||||
|
const file = byPath.get(normalize(item.href));
|
||||||
|
if (!file) return createCover();
|
||||||
|
return { ...createCover(), kind: "image", image: fileToDataUri(file, item.mediaType || mimeFromExt(item.href)) };
|
||||||
|
}
|
||||||
|
|
||||||
function buildChapter(
|
function buildChapter(
|
||||||
file: RawFile,
|
file: RawFile,
|
||||||
baseDir: string,
|
baseDir: string,
|
||||||
@@ -394,18 +493,21 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
|||||||
spine.forEach((item, i) => {
|
spine.forEach((item, i) => {
|
||||||
const file = byPath.get(normalize(item.href));
|
const file = byPath.get(normalize(item.href));
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
chapters.push(
|
try {
|
||||||
buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest),
|
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.");
|
if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found.");
|
||||||
|
|
||||||
const identifier = localText(opf, "identifier");
|
|
||||||
const metadata: BookMetadata = {
|
const metadata: BookMetadata = {
|
||||||
title: localText(opf, "title") || fallbackName,
|
title: localText(opf, "title") || fallbackName,
|
||||||
subtitle: "",
|
subtitle: "",
|
||||||
author: localText(opf, "creator"),
|
author: localText(opf, "creator"),
|
||||||
isbn: /\d{9,13}/.test(identifier) ? identifier.replace(/[^0-9Xx]/g, "") : "",
|
isbn: findIsbn(opf),
|
||||||
language: localText(opf, "language") || "en",
|
language: localText(opf, "language") || "en",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -414,8 +516,8 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
metadata,
|
metadata,
|
||||||
theme: "quiet-press",
|
theme: "quiet-press",
|
||||||
settings: { trim: "6x9", bleed: true },
|
settings: { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS },
|
||||||
cover: createCover(),
|
cover: findCover(opf, manifest, byPath),
|
||||||
chapters,
|
chapters,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,32 @@ export interface ImageInput {
|
|||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function compilePdf(source: string, images: ImageInput[] = []): Promise<Uint8Array> {
|
export interface PdfFonts {
|
||||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images });
|
bundled: string[];
|
||||||
|
system: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compilePdf(
|
||||||
|
source: string,
|
||||||
|
images: ImageInput[] = [],
|
||||||
|
emitWarnings = false,
|
||||||
|
fonts: PdfFonts = { bundled: [], system: [] },
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const buffer = await invoke<ArrayBuffer>("compile_pdf", {
|
||||||
|
source,
|
||||||
|
images,
|
||||||
|
emitWarnings,
|
||||||
|
bundledFonts: fonts.bundled,
|
||||||
|
systemFonts: fonts.system,
|
||||||
|
});
|
||||||
return new Uint8Array(buffer);
|
return new Uint8Array(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listSystemFonts(): Promise<string[]> {
|
||||||
|
if (!isDesktop) return [];
|
||||||
|
try {
|
||||||
|
return await invoke<string[]>("list_system_fonts");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { isDesktop } from "./ipc";
|
import { isDesktop } from "./ipc";
|
||||||
import { type Book, createBook, starterBook } from "./model/book";
|
import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book";
|
||||||
|
|
||||||
export interface BookSummary {
|
export interface BookSummary {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
corrupt?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listBooks(): Promise<BookSummary[]> {
|
export async function listBooks(): Promise<BookSummary[]> {
|
||||||
@@ -15,7 +16,16 @@ export async function listBooks(): Promise<BookSummary[]> {
|
|||||||
|
|
||||||
export async function loadBook(id: string): Promise<Book> {
|
export async function loadBook(id: string): Promise<Book> {
|
||||||
const contents = await invoke<string>("load_book", { id });
|
const contents = await invoke<string>("load_book", { id });
|
||||||
return JSON.parse(contents) as Book;
|
let book: Book;
|
||||||
|
try {
|
||||||
|
book = JSON.parse(contents) as Book;
|
||||||
|
} catch {
|
||||||
|
throw new Error("the book file is corrupt or unreadable");
|
||||||
|
}
|
||||||
|
if (schemaVersion(book.schema) > SCHEMA_VERSION) {
|
||||||
|
throw new Error("this book was made with a newer version of Margin; update the app to open it");
|
||||||
|
}
|
||||||
|
return book;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveBook(book: Book): Promise<void> {
|
export async function saveBook(book: Book): Promise<void> {
|
||||||
@@ -35,3 +45,18 @@ export function newBook(): Book {
|
|||||||
export function exampleBook(): Book {
|
export function exampleBook(): Book {
|
||||||
return starterBook();
|
return starterBook();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createAndOpenBook(
|
||||||
|
open: (book: Book) => void,
|
||||||
|
onError?: (message: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const book = newBook();
|
||||||
|
if (isDesktop) {
|
||||||
|
try {
|
||||||
|
await saveBook(book);
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(`Could not create book: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open(book);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
|
import { type BookFonts, DEFAULT_FONTS } from "./fonts";
|
||||||
|
|
||||||
export type TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5";
|
export type TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5";
|
||||||
export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top";
|
export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top";
|
||||||
@@ -24,6 +25,7 @@ export interface BookMetadata {
|
|||||||
export interface BookSettings {
|
export interface BookSettings {
|
||||||
trim: TrimSize;
|
trim: TrimSize;
|
||||||
bleed: boolean;
|
bleed: boolean;
|
||||||
|
fonts: BookFonts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CoverKind = "default" | "image";
|
export type CoverKind = "default" | "image";
|
||||||
@@ -115,6 +117,14 @@ export interface Book {
|
|||||||
chapters: Chapter[];
|
chapters: Chapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
export function schemaVersion(schema: unknown): number {
|
||||||
|
if (typeof schema !== "string") return 0;
|
||||||
|
const match = /^margin\/(\d+)$/.exec(schema);
|
||||||
|
return match ? Number(match[1]) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function paragraph(text: string): JSONContent {
|
function paragraph(text: string): JSONContent {
|
||||||
return { type: "paragraph", content: [{ type: "text", text }] };
|
return { type: "paragraph", content: [{ type: "text", text }] };
|
||||||
}
|
}
|
||||||
@@ -140,11 +150,19 @@ export function createCover(): Cover {
|
|||||||
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
|
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
|
||||||
|
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS };
|
||||||
|
|
||||||
export function normalizeBook(book: Book): Book {
|
export function normalizeBook(book: Book): Book {
|
||||||
|
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
|
||||||
return {
|
return {
|
||||||
...book,
|
...book,
|
||||||
|
metadata: { ...DEFAULT_METADATA, ...book.metadata },
|
||||||
|
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
|
||||||
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
|
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
|
||||||
chapters: book.chapters.map((c) => (c.updatedAt ? c : { ...c, updatedAt: Date.now() })),
|
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
|
||||||
|
c.updatedAt ? c : { ...c, updatedAt: Date.now() },
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +170,9 @@ export function createBook(): Book {
|
|||||||
return {
|
return {
|
||||||
schema: "margin/1",
|
schema: "margin/1",
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" },
|
metadata: { ...DEFAULT_METADATA },
|
||||||
theme: "quiet-press",
|
theme: "quiet-press",
|
||||||
settings: { trim: "6x9", bleed: true },
|
settings: { ...DEFAULT_SETTINGS },
|
||||||
cover: createCover(),
|
cover: createCover(),
|
||||||
chapters: [createChapter("Chapter One")],
|
chapters: [createChapter("Chapter One")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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] };
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
createPage,
|
createPage,
|
||||||
normalizeBook,
|
normalizeBook,
|
||||||
} from "../model/book";
|
} from "../model/book";
|
||||||
|
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
|
||||||
|
import { saveBook } from "../library";
|
||||||
|
import { applyBookFonts, resetBookFonts } from "../book-style";
|
||||||
|
|
||||||
export const COVER_ID = "__cover__";
|
export const COVER_ID = "__cover__";
|
||||||
|
|
||||||
@@ -40,7 +43,11 @@ interface BookState {
|
|||||||
markSaved: () => void;
|
markSaved: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBook = create<BookState>((set) => ({
|
function flushOutgoing(book: Book | null, dirty: boolean): void {
|
||||||
|
if (book && dirty) saveBook(book).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBook = create<BookState>((set, get) => ({
|
||||||
book: null,
|
book: null,
|
||||||
activeChapterId: "",
|
activeChapterId: "",
|
||||||
dirty: false,
|
dirty: false,
|
||||||
@@ -49,10 +56,19 @@ export const useBook = create<BookState>((set) => ({
|
|||||||
setExporting: (label) => set({ exporting: label }),
|
setExporting: (label) => set({ exporting: label }),
|
||||||
setNotice: (message) => set({ notice: message }),
|
setNotice: (message) => set({ notice: message }),
|
||||||
openBook: (book) => {
|
openBook: (book) => {
|
||||||
|
flushOutgoing(get().book, get().dirty);
|
||||||
const normalized = normalizeBook(book);
|
const normalized = normalizeBook(book);
|
||||||
set({ book: normalized, activeChapterId: normalized.chapters[0]?.id ?? "", dirty: false });
|
const saved = loadActiveChapter(normalized.id);
|
||||||
|
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
|
||||||
|
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
|
||||||
|
applyBookFonts(normalized.settings.fonts);
|
||||||
|
set({ book: normalized, activeChapterId, dirty: false });
|
||||||
|
},
|
||||||
|
closeBook: () => {
|
||||||
|
flushOutgoing(get().book, get().dirty);
|
||||||
|
resetBookFonts();
|
||||||
|
set({ book: null, activeChapterId: "", dirty: false });
|
||||||
},
|
},
|
||||||
closeBook: () => set({ book: null, activeChapterId: "", dirty: false }),
|
|
||||||
setActiveChapter: (id) => set({ activeChapterId: id }),
|
setActiveChapter: (id) => set({ activeChapterId: id }),
|
||||||
setChapterContent: (id, content) =>
|
setChapterContent: (id, content) =>
|
||||||
set((s) =>
|
set((s) =>
|
||||||
@@ -127,8 +143,21 @@ export const useBook = create<BookState>((set) => ({
|
|||||||
setMetadata: (patch) =>
|
setMetadata: (patch) =>
|
||||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
|
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
|
||||||
setSettings: (patch) =>
|
setSettings: (patch) =>
|
||||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, settings: { ...s.book.settings, ...patch } } } : {})),
|
set((s) => {
|
||||||
|
if (!s.book) return {};
|
||||||
|
const book = { ...s.book, settings: { ...s.book.settings, ...patch } };
|
||||||
|
if (patch.fonts) applyBookFonts(book.settings.fonts);
|
||||||
|
return { dirty: true, book };
|
||||||
|
}),
|
||||||
setCover: (patch) =>
|
setCover: (patch) =>
|
||||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
|
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
|
||||||
markSaved: () => set({ dirty: false }),
|
markSaved: () => set({ dirty: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let lastActive = "";
|
||||||
|
useBook.subscribe((s) => {
|
||||||
|
if (s.book && s.activeChapterId && s.activeChapterId !== lastActive) {
|
||||||
|
lastActive = s.activeChapterId;
|
||||||
|
saveActiveChapter(s.book.id, s.activeChapterId);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { type PreviewMode, initialPreviewMode, persistPreviewMode } from "../devices";
|
||||||
|
|
||||||
|
interface PreviewState {
|
||||||
|
mode: PreviewMode;
|
||||||
|
setMode: (mode: PreviewMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePreviewMode = create<PreviewState>((set) => ({
|
||||||
|
mode: initialPreviewMode(),
|
||||||
|
setMode: (mode) => {
|
||||||
|
persistPreviewMode(mode);
|
||||||
|
set({ mode });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -427,6 +427,7 @@ body.resizing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chapter-title {
|
.chapter-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 31px;
|
font-size: 31px;
|
||||||
line-height: 1.16;
|
line-height: 1.16;
|
||||||
@@ -434,6 +435,10 @@ body.resizing {
|
|||||||
margin: 15px 0 0;
|
margin: 15px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose :is(h1, h2, h3) {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
.prose p {
|
.prose p {
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
line-height: 1.68;
|
line-height: 1.68;
|
||||||
@@ -565,11 +570,15 @@ body.resizing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page .p-title {
|
.page .p-title {
|
||||||
font-family: var(--font-book);
|
font-family: var(--font-heading);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page :is(h2, h3) {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
.page p {
|
.page p {
|
||||||
font-family: var(--font-book);
|
font-family: var(--font-book);
|
||||||
font-size: 6.6px;
|
font-size: 6.6px;
|
||||||
@@ -620,7 +629,7 @@ body.resizing {
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--font-book);
|
font-family: var(--font-heading);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 31px;
|
font-size: 31px;
|
||||||
line-height: 1.16;
|
line-height: 1.16;
|
||||||
@@ -660,6 +669,87 @@ body.resizing {
|
|||||||
accent-color: var(--ink-soft);
|
accent-color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-presets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-preset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
min-width: 70px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--raised);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-preset:hover {
|
||||||
|
border-color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-preset[data-on="true"] {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-preset-demo {
|
||||||
|
font-size: 23px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-preset-name {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-1);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-advanced {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-note {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-1);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-sample {
|
||||||
|
padding: 15px 17px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-sample-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-host,
|
.editor-host,
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -1167,6 +1257,10 @@ body.resizing {
|
|||||||
|
|
||||||
.library-head {
|
.library-head {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shelf {
|
.shelf {
|
||||||
@@ -1209,6 +1303,16 @@ body.resizing {
|
|||||||
box-shadow: 0 4px 10px rgba(35, 32, 27, 0.1), 0 20px 38px rgba(35, 32, 27, 0.13);
|
box-shadow: 0 4px 10px rgba(35, 32, 27, 0.1), 0 20px 38px rgba(35, 32, 27, 0.13);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-corrupt {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.65;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-corrupt:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-family: var(--font-book);
|
font-family: var(--font-book);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -1422,7 +1526,7 @@ body.resizing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cover-title {
|
.cover-title {
|
||||||
font-family: var(--font-book);
|
font-family: var(--font-heading);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 9.5cqw;
|
font-size: 9.5cqw;
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
@@ -1992,3 +2096,361 @@ body.resizing {
|
|||||||
background: var(--accent-wash);
|
background: var(--accent-wash);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-stage {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-block: 6px;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-frame {
|
||||||
|
--dv-scale: calc(100cqi / 380);
|
||||||
|
width: 100%;
|
||||||
|
padding: calc(var(--dv-bezel-w) * var(--dv-scale));
|
||||||
|
background: var(--dv-bezel);
|
||||||
|
border-radius: calc(var(--dv-radius) * var(--dv-scale));
|
||||||
|
box-shadow: var(--shadow-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-screen {
|
||||||
|
aspect-ratio: var(--dv-aspect);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--dv-bg);
|
||||||
|
color: var(--dv-ink);
|
||||||
|
border-radius: calc(max(0px, (var(--dv-radius) - var(--dv-bezel-w)) * var(--dv-scale)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-statusbar {
|
||||||
|
flex: none;
|
||||||
|
height: calc(30 * var(--dv-scale));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 calc(18 * var(--dv-scale));
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: calc(11 * var(--dv-scale));
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dv-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-clock {
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-batt {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
width: calc(22 * var(--dv-scale));
|
||||||
|
height: calc(11 * var(--dv-scale));
|
||||||
|
border: calc(1.4 * var(--dv-scale)) solid var(--dv-ink);
|
||||||
|
border-radius: calc(3 * var(--dv-scale));
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-batt::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: calc(1.5 * var(--dv-scale));
|
||||||
|
bottom: calc(1.5 * var(--dv-scale));
|
||||||
|
left: calc(1.5 * var(--dv-scale));
|
||||||
|
width: 55%;
|
||||||
|
background: var(--dv-ink);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-batt::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: calc(-3 * var(--dv-scale));
|
||||||
|
top: 30%;
|
||||||
|
height: 40%;
|
||||||
|
width: calc(2 * var(--dv-scale));
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--dv-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: calc(var(--dv-my) * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale));
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: calc(var(--dv-fs) * var(--dv-scale));
|
||||||
|
line-height: var(--dv-lh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-frame[data-kind="eink"] .device-content::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-opener {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: calc(20 * var(--dv-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-eyebrow {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: calc(10 * var(--dv-scale));
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: calc(8 * var(--dv-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: calc(24 * var(--dv-scale));
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body p {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
hyphens: auto;
|
||||||
|
margin: 0 0 0.8em;
|
||||||
|
color: var(--dv-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body p[data-indent="true"] {
|
||||||
|
text-indent: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-frame[data-kind="eink"] .device-body p {
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-frame[data-kind="phone"] .device-body p,
|
||||||
|
.device-frame[data-kind="tablet"] .device-body p {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body h1,
|
||||||
|
.device-body h2,
|
||||||
|
.device-body h3 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 1.2em 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body blockquote {
|
||||||
|
margin: 0 0 0.8em;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 2px solid currentColor;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid currentColor;
|
||||||
|
width: calc(40 * var(--dv-scale));
|
||||||
|
margin: calc(16 * var(--dv-scale)) auto;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 0.8em auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-footer {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: calc(10 * var(--dv-scale));
|
||||||
|
padding: calc(8 * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale));
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: calc(11 * var(--dv-scale));
|
||||||
|
color: var(--dv-ink);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-foot-title {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-content .cover-art {
|
||||||
|
width: 100%;
|
||||||
|
margin: auto;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google Drive backup */
|
||||||
|
.backup-btn[data-state="pending"] {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-btn[data-state="error"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-btn[data-state="working"] svg {
|
||||||
|
animation: backup-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes backup-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-restore {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 44px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
transition: color 120ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-link:hover:not(:disabled) {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-link:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-intro,
|
||||||
|
.backup-connecting {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-intro p,
|
||||||
|
.backup-connecting p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-text-btn {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
transition: color 120ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-text-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-waiting {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-waiting svg {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
animation: backup-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-link-sep {
|
||||||
|
color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-link-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-account {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-account-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-email {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
color: var(--ink);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
margin: 18px 0;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-3);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-dot.is-synced {
|
||||||
|
background: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-dot.is-pending {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-note {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-2);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
@@ -29,3 +29,67 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-display: block;
|
font-display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
src: url("/fonts/EBGaramond-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 400 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
src: url("/fonts/EBGaramond-Italic-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 400 800;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Lora";
|
||||||
|
src: url("/fonts/Lora-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Lora";
|
||||||
|
src: url("/fonts/Lora-Italic-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Source Serif 4";
|
||||||
|
src: url("/fonts/SourceSerif4-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Source Serif 4";
|
||||||
|
src: url("/fonts/SourceSerif4-Italic-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Fraunces";
|
||||||
|
src: url("/fonts/Fraunces-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Fraunces";
|
||||||
|
src: url("/fonts/Fraunces-Italic-VF.ttf") format("truetype-variations");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
--font-book: "Literata", Georgia, "Times New Roman", serif;
|
--font-book: "Literata", Georgia, "Times New Roman", serif;
|
||||||
|
--font-heading: "Literata", Georgia, "Times New Roman", serif;
|
||||||
|
|
||||||
--r-sm: 5px;
|
--r-sm: 5px;
|
||||||
--r-md: 8px;
|
--r-md: 8px;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default defineConfig(async () => ({
|
|||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
// 3. tell Vite to ignore watching `src-tauri`
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
ignored: ["**/src-tauri/**"],
|
ignored: ["**/src-tauri/**", "**/website/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.astro/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# margin website
|
||||||
|
|
||||||
|
The marketing site for **Margin** — the offline book-writing and publishing app. Built with
|
||||||
|
[Astro](https://astro.build), it's a single landing page that ships almost no JavaScript and
|
||||||
|
reuses the app's *Quiet Press* design system (Literata + Hanken Grotesk, warm light/dark themes).
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd website
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # http://localhost:4321
|
||||||
|
pnpm build # static output to website/dist/
|
||||||
|
pnpm preview # preview the production build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where to change things
|
||||||
|
|
||||||
|
Almost everything lives in **`src/data/site.ts`**:
|
||||||
|
|
||||||
|
- `price` — the one-time price (`"$5"` / `"$10"` …). Shown everywhere automatically.
|
||||||
|
- `repo` — the GitHub repo (`priyanshujain/margin`) that hosts release artifacts.
|
||||||
|
- `buyUrl` — point this at a payment processor (Gumroad / Polar / Stripe / Lemon Squeezy)
|
||||||
|
when one is wired up; until then it falls back to the GitHub releases page.
|
||||||
|
- `faqs`, `compare`, `publishTargets` — the page content.
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
Download buttons resolve the **latest GitHub release** asset per OS at runtime
|
||||||
|
(`src/components/DownloadScript.astro`):
|
||||||
|
|
||||||
|
- macOS → `.dmg`
|
||||||
|
- Windows → `.msi`
|
||||||
|
- Linux → `.AppImage` (falls back to `.deb` / `.rpm`)
|
||||||
|
|
||||||
|
The OS is auto-detected for the primary button. If the GitHub API is unreachable or there's no
|
||||||
|
published release yet, every button falls back to the releases page — so they always work.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
`pnpm build` produces a fully static `dist/` that hosts anywhere — GitHub Pages, Cloudflare
|
||||||
|
Pages, Netlify, Vercel. Set the real domain in `astro.config.mjs` (`site`) for correct
|
||||||
|
canonical / Open Graph URLs.
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
|
||||||
|
`public/fonts/` carries the same OFL variable fonts the app bundles (Literata, Hanken Grotesk),
|
||||||
|
so the site matches the product exactly.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
|
// The canonical URL is only used for absolute links (OG tags, sitemap).
|
||||||
|
// Change it to the real domain when the site goes live.
|
||||||
|
export default defineConfig({
|
||||||
|
site: "https://margin.73ai.org",
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="350" height="350" fill="white"/>
|
||||||
|
<path d="M263.799 32.9229C266.952 32.1427 270 34.5286 270 37.7764V312.13C270 315.392 266.926 317.781 263.765 316.975L83.7646 271.075C81.5501 270.51 80.0002 268.516 80 266.23V82.3271C80 80.0284 81.5675 78.0259 83.7988 77.4736L263.799 32.9229Z" stroke="black" stroke-width="10"/>
|
||||||
|
<rect x="231" y="69" width="10" height="212" rx="5" fill="black"/>
|
||||||
|
<rect x="204" y="85" width="10" height="180" rx="5" fill="black"/>
|
||||||
|
<rect x="104" y="135" width="10" height="80" rx="5" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 632 B |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "margin-website",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"deploy": "astro build && npx wrangler pages deploy dist --project-name=margin --commit-dirty=true"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "^5.6.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright 2021 The Hanken Grotesk Project Authors (https://github.com/marcologous/hanken-grotesk)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M263.799 32.9229C266.952 32.1427 270 34.5286 270 37.7764V312.13C270 315.392 266.926 317.781 263.765 316.975L83.7646 271.075C81.5501 270.51 80.0002 268.516 80 266.23V82.3271C80 80.0284 81.5675 78.0259 83.7988 77.4736L263.799 32.9229Z" stroke="#ece6da" stroke-width="10"/>
|
||||||
|
<rect x="231" y="69" width="10" height="212" rx="5" fill="#ece6da"/>
|
||||||
|
<rect x="204" y="85" width="10" height="180" rx="5" fill="#ece6da"/>
|
||||||
|
<rect x="104" y="135" width="10" height="80" rx="5" fill="#ece6da"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M263.799 32.9229C266.952 32.1427 270 34.5286 270 37.7764V312.13C270 315.392 266.926 317.781 263.765 316.975L83.7646 271.075C81.5501 270.51 80.0002 268.516 80 266.23V82.3271C80 80.0284 81.5675 78.0259 83.7988 77.4736L263.799 32.9229Z" stroke="#23201b" stroke-width="10"/>
|
||||||
|
<rect x="231" y="69" width="10" height="212" rx="5" fill="#23201b"/>
|
||||||
|
<rect x="204" y="85" width="10" height="180" rx="5" fill="#23201b"/>
|
||||||
|
<rect x="104" y="135" width="10" height="80" rx="5" fill="#23201b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" fill-rule="evenodd"><path d="M28.312 28.26C25.003 30.7 20.208 32 16.08 32c-5.8 0-11.002-2.14-14.945-5.703-.3-.28-.032-.662.34-.444C5.73 28.33 11 29.82 16.426 29.82a29.73 29.73 0 0 0 11.406-2.332c.56-.238 1.03.367.48.773m1.376-1.575c-.42-.54-2.796-.255-3.86-.13-.325.04-.374-.243-.082-.446 1.9-1.33 4.994-.947 5.356-.5s-.094 3.56-1.87 5.044c-.273.228-.533.107-.4-.196.4-.996 1.294-3.23.87-3.772" fill="#f90"/><path d="M18.43 13.864c0 1.692.043 3.103-.812 4.605-.7 1.22-1.8 1.973-3.005 1.973-1.667 0-2.644-1.27-2.644-3.145 0-3.7 3.316-4.373 6.462-4.373v.94m4.38 10.584c-.287.257-.702.275-1.026.104-1.44-1.197-1.704-1.753-2.492-2.895-2.382 2.43-4.074 3.157-7.158 3.157-3.658 0-6.498-2.254-6.498-6.767 0-3.524 1.905-5.924 4.63-7.097 2.357-1.038 5.65-1.22 8.165-1.5V8.9c0-1.032.08-2.254-.53-3.145-.525-.8-1.54-1.13-2.437-1.13-1.655 0-3.127.85-3.487 2.608-.073.4-.36.776-.757.794L7 7.555c-.354-.08-.75-.366-.647-.9C7.328 1.54 11.945 0 16.074 0c2.113 0 4.874.562 6.54 2.162 2.113 1.973 1.912 4.605 1.912 7.47V16.4c0 2.034.843 2.925 1.637 4.025.275.4.336.86-.018 1.154a184.26 184.26 0 0 0-3.328 2.883l-.006-.012" fill="#221f1f"/><path d="M-29.65 355.868c-35 25.797-85.73 39.56-129.406 39.56-61.243 0-116.377-22.65-158.088-60.325-3.277-2.963-.34-7 3.592-4.693 45.014 26.2 100.673 41.947 158.166 41.947 38.775 0 81.43-8.022 120.65-24.67 5.925-2.517 10.88 3.88 5.086 8.18m14.55-16.647c-4.457-5.715-29.573-2.7-40.846-1.363-3.434.42-3.96-2.57-.865-4.72 20.003-14.078 52.827-10.015 56.655-5.296 3.828 4.745-.996 37.647-19.794 53.35-2.884 2.412-5.637 1.127-4.352-2.07 4.22-10.54 13.685-34.16 9.202-39.902" fill="#f90"/><path d="M-55.16 233.75v-13.685c0-2.07 1.573-3.46 3.46-3.46H9.57c1.966 0 3.54 1.416 3.54 3.46v11.72c-.026 1.966-1.678 4.536-4.614 8.6l-31.75 45.33c11.798-.288 24.25 1.468 34.947 7.498 2.412 1.363 3.067 3.356 3.25 5.322v14.603c0 1.992-2.202 4.326-4.5 3.12-18.85-9.884-43.887-10.96-64.73.105-2.124 1.154-4.352-1.154-4.352-3.146v-13.87c0-2.228.026-6.03 2.255-9.412l36.782-52.748h-32c-1.966 0-3.54-1.4-3.54-3.434m-223.495 85.385h-18.64c-1.783-.13-3.198-1.468-3.33-3.172V220.3c0-1.914 1.6-3.434 3.592-3.434h17.382c1.8.08 3.25 1.468 3.382 3.198v12.505h.34c4.536-12.086 13.056-17.723 24.54-17.723 11.666 0 18.955 5.637 24.198 17.723 4.5-12.086 14.76-17.723 25.745-17.723 7.813 0 16.36 3.225 21.576 10.46 5.9 8.05 4.693 19.74 4.693 29.992l-.026 60.377c0 1.914-1.6 3.46-3.592 3.46h-18.614c-1.86-.13-3.356-1.625-3.356-3.46v-50.703c0-4.037.367-14.105-.524-17.932-1.4-6.423-5.558-8.232-10.96-8.232-4.5 0-9.228 3.015-11.142 7.84s-1.73 12.9-1.73 18.326v50.703c0 1.914-1.6 3.46-3.592 3.46h-18.614c-1.888-.13-3.356-1.625-3.356-3.46l-.026-50.703c0-10.67 1.757-26.374-11.483-26.374-13.397 0-12.872 15.3-12.872 26.374v50.703c0 1.914-1.6 3.46-3.592 3.46m344.496-104.3c27.66 0 42.63 23.752 42.63 53.954 0 29.18-16.543 52.33-42.63 52.33-27.16 0-41.947-23.752-41.947-53.35 0-29.782 14.97-52.932 41.947-52.932m.157 19.532c-13.738 0-14.603 18.72-14.603 30.385 0 11.693-.184 36.65 14.445 36.65 14.445 0 15.127-20.135 15.127-32.404 0-8.075-.34-17.723-2.78-25.378-2.097-6.66-6.266-9.255-12.2-9.255m78.338 84.758H125.8c-1.86-.13-3.356-1.625-3.356-3.46l-.026-95.7c.157-1.757 1.704-3.12 3.592-3.12h17.277c1.625.08 2.962 1.18 3.33 2.674v14.63h.34c5.217-13.082 12.532-19.322 25.404-19.322 8.363 0 16.517 3.015 21.76 11.273 4.876 7.655 4.876 20.528 4.876 29.782v60.22c-.2 1.678-1.757 3.015-3.592 3.015h-18.693c-1.704-.13-3.12-1.4-3.303-3.015v-51.962c0-10.46 1.206-25.77-11.667-25.77-4.535 0-8.704 3.04-10.775 7.655-2.622 5.846-2.962 11.667-2.962 18.116v51.516c-.026 1.914-1.652 3.46-3.644 3.46m66.293-7.594c0-4.824 4.116-8.704 9.176-8.704s9.176 3.88 9.176 8.704c0 4.798-4.116 8.73-9.176 8.73s-9.176-3.933-9.176-8.73m197.5 7.63c-1.94-.08-3.46-1.573-3.46-3.46V220.04c.105-1.704 1.547-3.04 3.33-3.146h6.843c1.888 0 3.408 1.363 3.565 3.146v13.947c4.876-11.064 13.947-19.715 25.404-19.715h1.4c12.165 0 21.052 8.966 24.355 21.996 5.165-12.872 14.865-21.996 27.66-21.996h1.416c9.045 0 17.75 5.82 22.258 14.68 4.352 8.468 4.195 19.74 4.195 29.206l-.026 57.546c.026 1.835-1.468 3.33-3.33 3.46h-8.18c-1.783-.08-3.225-1.337-3.46-3v-58.018c0-6.843.34-14.105-2.438-20.344-2.83-6.37-8.258-10.356-14.078-10.644-6.502.315-12.48 5.06-16.36 11.457-5.034 8.258-4.85 15.704-4.85 25.352v52.25c-.236 1.573-1.625 2.805-3.33 2.936h-8.127c-1.94-.08-3.487-1.573-3.487-3.46l-.052-61.374c0-5.637-.34-12.27-2.936-17.33-3.015-5.768-8.416-9.543-14.078-9.83-5.873.34-11.798 4.824-15.3 10.04-4.536 6.66-5.4 14.9-5.4 23.36v55.134c0 1.835-1.494 3.33-3.356 3.46h-8.153M352 321.157c-26.453 0-38.303-26.977-38.303-53.954 0-28.367 13.92-52.932 40.557-52.932h1.416c25.902 0 38.8 26.164 38.8 53.142 0 28.576-14.288 53.745-41.082 53.745h-1.4m1.94-13.082c8.704-.288 15.573-5.7 19.636-14.68 3.644-8.075 4.352-17.33 4.352-26.2 0-9.648-1.05-19.715-5.584-27.973-4.064-7.2-11.037-11.798-18.43-12.06-8.232.288-15.6 5.873-19.296 14.472-3.33 7.446-4.352 17.33-4.352 25.56 0 9.255 1.206 19.95 5.034Line truncated
|
||||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
|
||||||
|
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
|
||||||
|
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
|
||||||
|
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
|
||||||
|
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
|
||||||
|
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
|
||||||
|
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
|
||||||
|
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
|
||||||
|
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
|
||||||
|
]>
|
||||||
|
<svg version="1.1"
|
||||||
|
id="svg2" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 902.7 902.7"
|
||||||
|
style="enable-background:new 0 0 902.7 902.7;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:url(#SVGID_1_);}
|
||||||
|
.st1{fill:url(#SVGID_2_);}
|
||||||
|
.st2{fill:url(#SVGID_3_);}
|
||||||
|
</style>
|
||||||
|
<metadata id="metadata7">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"></dc:type>
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
<sfw xmlns="&ns_sfw;">
|
||||||
|
<slices></slices>
|
||||||
|
<sliceSourceBounds bottomLeftOrigin="true" height="902.7" width="902.7" x="6.1" y="16.6"></sliceSourceBounds>
|
||||||
|
</sfw>
|
||||||
|
</metadata>
|
||||||
|
<g>
|
||||||
|
|
||||||
|
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="451.368" y1="908.6044" x2="451.368" y2="22.3235" gradientTransform="matrix(1 0 0 -1 0 916.586)">
|
||||||
|
<stop offset="0" style="stop-color:#FFBB1C"/>
|
||||||
|
<stop offset="1" style="stop-color:#AE1A00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<path class="st0" d="M451.4,0C202.1,0,0,202.1,0,451.4s202.1,451.4,451.4,451.4s451.4-202.1,451.4-451.4l0,0
|
||||||
|
C902.7,202.1,700.7,0,451.4,0z"/>
|
||||||
|
|
||||||
|
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="451.3675" y1="872.9812" x2="451.3675" y2="57.9072" gradientTransform="matrix(1 0 0 -1 0 916.586)">
|
||||||
|
<stop offset="0" style="stop-color:#FEC216"/>
|
||||||
|
<stop offset="1" style="stop-color:#DD4C01"/>
|
||||||
|
</linearGradient>
|
||||||
|
<path class="st1" d="M866.5,451.4c0,229.3-185.8,415.1-415.1,415.1S36.3,680.6,36.3,451.4S222.1,36.3,451.4,36.3
|
||||||
|
S866.5,222.1,866.5,451.4L866.5,451.4z"/>
|
||||||
|
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="451.2435" y1="245.6635" x2="451.2435" y2="692.1786">
|
||||||
|
<stop offset="0" style="stop-color:#EEEEEE"/>
|
||||||
|
<stop offset="1" style="stop-color:#D0D0CD"/>
|
||||||
|
</linearGradient>
|
||||||
|
<path class="st2" d="M441.3,331.8c0-17.4-16.2-41.1-29.8-54.1c-21.8-21-50.7-33.3-87.3-37.1c-38-4-85.5,8-118.1,29.7
|
||||||
|
c-18.2,12.1-25.3,21.3-27.3,35.6c-0.6,4-0.8,72.1-0.7,168.7c0.2,91.6,0.4,139.1,0.5,161.2c0,6.6,7.5,10.4,12.9,6.5
|
||||||
|
c4-2.9,45.9-29.4,69-34.3c17-4.4,30.7-6.1,48.4-6.1c42,0,77.5,14.6,104.4,42.9c17.6,18.1,27.9,49.9,27.9,49.9
|
||||||
|
S441.3,385.6,441.3,331.8z M461.2,694.5c0,0,10.4-31.8,27.9-49.9c26.9-28.3,62.4-42.9,104.4-42.9c17.6,0,31.4,1.7,48.4,6.1
|
||||||
|
c23.2,4.9,65.1,31.5,69,34.3c5.3,3.9,12.9,0.1,12.9-6.5c0.1-22.2,0.4-69.6,0.5-161.2c0.2-96.6-0.1-164.7-0.7-168.7
|
||||||
|
c-2-14.2-9-23.4-27.3-35.6c-32.6-21.7-80.1-33.6-118.1-29.7c-36.6,3.8-65.5,16.1-87.3,37.1c-13.6,13.1-29.8,36.8-29.8,54.1
|
||||||
|
C461.2,385.6,461.2,694.5,461.2,694.5z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 64 64" width="64" height="64" style="enable-background:new 0 0 64 64;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#35614A;}
|
||||||
|
.st1{fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<path class="st0" d="M58.8,64H5.2C2.3,64,0,61.7,0,58.8V5.2C0,2.3,2.3,0,5.2,0h53.5C61.7,0,64,2.3,64,5.2v53.5
|
||||||
|
C64,61.7,61.7,64,58.8,64z"/>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M42.2,30.1c0.3,5.7,0.4,7.3,0.4,12.2v10.7h-7.1V11h7.1c0,0,3.4,12.3,4.1,15c0.8,2.6,1.3,4.9,1.7,6.6
|
||||||
|
c-0.2-4.1-0.3-9.2-0.3-13.4v-8.3H55v42h-6.4c0,0-3.9-13-4.7-15.5C43.3,35.5,42.8,33.2,42.2,30.1L42.2,30.1z"/>
|
||||||
|
<path class="st1" d="M22.9,30.6c3.4-0.2,5.4-2.9,5.4-8.1c0-7.2-2-11.6-9.5-11.6H9v42.1h9.8c6.7,0,10.7-3.2,10.7-11.9
|
||||||
|
C29.5,34.3,26.7,30.6,22.9,30.6z M16.5,15.8h1.4c2.5,0,3,1.8,3,6.7c0,3.6-0.4,5.4-2.5,5.4h-1.9V15.8z M18.5,48.1h-1.9V33.2h1.8
|
||||||
|
c2.4,0,3,2.3,3,7C21.4,46.7,20.8,48.1,18.5,48.1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="146px" height="27px" viewBox="0 0 146 27" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 52.3 (67297) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title>Artboard</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<g id="Artboard" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="Group-2">
|
||||||
|
<g id="Group" transform="translate(0.959956, 0.687500)">
|
||||||
|
<rect id="Rectangle-path" x="0" y="0" width="144.061402" height="25.5"></rect>
|
||||||
|
<g opacity="0.54" transform="translate(83.719689, 2.062500)" fill="#000000" fill-rule="nonzero" id="Shape">
|
||||||
|
<path d="M0.037374861,17.25 L0.037374861,0.075 L6.24160178,0.075 C7.58709677,0.075 8.74571746,0.525 9.71746385,1.40625 C10.6892102,2.2875 11.1937709,3.4125 11.1937709,4.7625 C11.1937709,5.5875 10.9882091,6.3 10.5583982,6.91875 C10.1285873,7.5375 9.5679644,8.00625 8.87652948,8.34375 L8.87652948,8.4375 C9.71746385,8.71875 10.4088988,9.225 10.9508343,9.91875 C11.4927697,10.63125 11.7730812,11.4375 11.7730812,12.375 C11.7730812,13.78125 11.2498331,14.94375 10.2220245,15.88125 C9.1942158,16.8 7.97953281,17.26875 6.57797553,17.26875 L0.037374861,17.26875 L0.037374861,17.25 Z M2.24249166,7.425 L6.24160178,7.425 C7.06384872,7.425 7.73659622,7.14375 8.24115684,6.6 C8.74571746,6.05625 8.98865406,5.4375 8.98865406,4.78125 C8.98865406,4.125 8.74571746,3.525 8.25984427,2.98125 C7.77397108,2.4375 7.13859844,2.15625 6.33503893,2.15625 L2.24249166,2.15625 L2.24249166,7.425 Z M2.24249166,15.13125 L6.67141268,15.13125 C7.51234705,15.13125 8.20378198,14.83125 8.72703003,14.25 C9.25027809,13.65 9.51190211,13.0125 9.51190211,12.3 C9.51190211,11.5875 9.23159066,10.95 8.68965517,10.36875 C8.14771969,9.7875 7.43759733,9.4875 6.5592881,9.4875 L2.24249166,9.4875 L2.24249166,15.13125 Z"></path>
|
||||||
|
<path d="M14.7817575,6.9 C15.9216908,5.7 17.3606229,5.11875 19.1172414,5.11875 C20.8738598,5.11875 22.312792,5.71875 23.4527253,6.9 C24.5926585,8.1 25.1532814,9.58125 25.1532814,11.38125 C25.1532814,13.18125 24.5926585,14.6625 23.4527253,15.8625 C22.312792,17.0625 20.8738598,17.64375 19.1172414,17.64375 C17.3606229,17.64375 15.9216908,17.04375 14.7817575,15.8625 C13.6418242,14.6625 13.0812013,13.18125 13.0812013,11.38125 C13.0812013,9.58125 13.6418242,8.08125 14.7817575,6.9 Z M16.407564,14.45625 C17.1737486,15.225 18.0707453,15.61875 19.0985539,15.61875 C20.1263626,15.61875 21.0233593,15.225 21.7895439,14.45625 C22.5557286,13.6875 22.9294772,12.65625 22.9294772,11.38125 C22.9294772,10.10625 22.5557286,9.075 21.7895439,8.30625 C21.0233593,7.5375 20.1263626,7.14375 19.0985539,7.14375 C18.0707453,7.14375 17.1737486,7.5375 16.407564,8.30625 C15.6413793,9.075 15.2676307,10.10625 15.2676307,11.38125 C15.2863181,12.65625 15.6600667,13.6875 16.407564,14.45625 Z"></path>
|
||||||
|
<path d="M28.0498331,6.9 C29.1897664,5.7 30.6286986,5.11875 32.385317,5.11875 C34.1419355,5.11875 35.5808676,5.71875 36.7208009,6.9 C37.8607341,8.1 38.4213571,9.58125 38.4213571,11.38125 C38.4213571,13.18125 37.8607341,14.6625 36.7208009,15.8625 C35.5808676,17.0625 34.1419355,17.64375 32.385317,17.64375 C30.6286986,17.64375 29.1897664,17.04375 28.0498331,15.8625 C26.9098999,14.6625 26.349277,13.18125 26.349277,11.38125 C26.349277,9.58125 26.9098999,8.08125 28.0498331,6.9 Z M29.694327,14.45625 C30.4605117,15.225 31.3575083,15.61875 32.385317,15.61875 C33.4131257,15.61875 34.3101224,15.225 35.076307,14.45625 C35.8424917,13.6875 36.2162403,12.65625 36.2162403,11.38125 C36.2162403,10.10625 35.8424917,9.075 35.076307,8.30625 C34.3101224,7.5375 33.4131257,7.14375 32.385317,7.14375 C31.3575083,7.14375 30.4605117,7.5375 29.694327,8.30625 C28.9281424,9.075 28.5543938,10.10625 28.5543938,11.38125 C28.5543938,12.65625 28.9281424,13.6875 29.694327,14.45625 Z"></path>
|
||||||
|
<polygon points="42.3830923 0.075 42.3830923 10.65 47.447386 5.49375 50.269188 5.49375 50.269188 5.5875 45.7094549 10.1625 50.4934372 17.1375 50.4934372 17.23125 47.839822 17.23125 44.1583982 11.68125 42.3830923 13.4625 42.3830923 17.25 40.1779755 17.25 40.1779755 0.075"></polygon>
|
||||||
|
<path d="M55.7259177,17.625 C54.4177976,17.625 53.3339266,17.30625 52.4929922,16.66875 C51.6333704,16.03125 51.0166852,15.225 50.6242492,14.26875 L52.5864294,13.44375 C53.2031146,14.925 54.2682981,15.65625 55.7632925,15.65625 C56.4547275,15.65625 57.0153504,15.50625 57.4451613,15.20625 C57.8749722,14.90625 58.0992214,14.49375 58.0992214,14.00625 C58.0992214,13.2375 57.5572859,12.7125 56.4921023,12.45 L54.1187987,11.86875 C53.3713014,11.68125 52.6611791,11.30625 51.9884316,10.78125 C51.3156841,10.2375 50.9793103,9.525 50.9793103,8.60625 C50.9793103,7.575 51.4464961,6.73125 52.3621802,6.075 C53.2778643,5.41875 54.3617353,5.1 55.6324805,5.1 C56.6602892,5.1 57.5946607,5.34375 58.3982202,5.8125 C59.2204672,6.28125 59.7997775,6.95625 60.1361513,7.8375 L58.2300334,8.625 C57.8002225,7.59375 56.9032258,7.06875 55.5577308,7.06875 C54.9036707,7.06875 54.3617353,7.2 53.9132369,7.48125 C53.4647386,7.7625 53.2404894,8.11875 53.2404894,8.5875 C53.2404894,9.2625 53.7637375,9.7125 54.7915462,9.95625 L57.1087875,10.5 C58.2113459,10.7625 59.0149055,11.19375 59.5568409,11.8125 C60.080089,12.43125 60.341713,13.14375 60.341713,13.93125 C60.341713,14.98125 59.9119021,15.8625 59.0522803,16.575 C58.1926585,17.2875 57.0714127,17.625 55.7259177,17.625 Z"></path>
|
||||||
|
</g>
|
||||||
|
<g id="XMLID_36_">
|
||||||
|
<path d="M0.130812013,9.8625 C0.130812013,4.44375 4.67185762,0.01875 10.0912125,0.01875 C13.0812013,0.01875 15.2115684,1.2 16.8186874,2.7375 L14.931257,4.63125 C13.7913237,3.54375 12.2215795,2.71875 10.0912125,2.71875 C6.14816463,2.71875 3.0647386,5.90625 3.0647386,9.8625 C3.0647386,13.81875 6.14816463,17.00625 10.0912125,17.00625 C12.6513904,17.00625 14.10901,15.975 15.0433815,15.0375 C15.8095662,14.26875 16.3141268,13.1625 16.5010011,11.6625 L10.0912125,11.6625 L10.0912125,8.98125 L19.1172414,8.98125 C19.2106785,9.46875 19.2667408,10.03125 19.2667408,10.66875 C19.2667408,12.69375 18.7248053,15.1875 16.9494994,16.95 C15.2302558,18.75 13.025139,19.70625 10.1098999,19.70625 C4.67185762,19.6875 0.130812013,15.28125 0.130812013,9.8625 Z" id="XMLID_26_" fill="#4285F4" fill-rule="nonzero"></path>
|
||||||
|
<path d="M26.5361513,7.03125 C23.0416018,7.03125 20.2011123,9.69375 20.2011123,13.36875 C20.2011123,17.025 23.0416018,19.70625 26.5361513,19.70625 C30.0307008,19.70625 32.8711902,17.025 32.8711902,13.36875 C32.8711902,9.69375 30.0307008,7.03125 26.5361513,7.03125 Z M26.5361513,17.19375 C24.6300334,17.19375 22.9668521,15.61875 22.9668521,13.35 C22.9668521,11.0625 24.6113459,9.50625 26.5361513,9.50625 C28.4422692,9.50625 30.1054505,11.0625 30.1054505,13.35 C30.1054505,15.61875 28.4422692,17.19375 26.5361513,17.19375 Z" id="XMLID_22_" fill="#EA4335" fill-rule="nonzero"></path>
|
||||||
|
<path d="M40.3648498,7.03125 C36.8703003,7.03125 34.0298109,9.69375 34.0298109,13.36875 C34.0298109,17.025 36.8703003,19.70625 40.3648498,19.70625 C43.8593993,19.70625 46.6998888,17.025 46.6998888,13.36875 C46.6998888,9.69375 43.8593993,7.03125 40.3648498,7.03125 Z M40.3648498,17.19375 C38.4587319,17.19375 36.7955506,15.61875 36.7955506,13.35 C36.7955506,11.0625 38.4400445,9.50625 40.3648498,9.50625 C42.2896552,9.50625 43.9341491,11.0625 43.9341491,13.35 C43.9341491,15.61875 42.2709677,17.19375 40.3648498,17.19375 Z" id="XMLID_19_" fill="#FBBC05" fill-rule="nonzero"></path>
|
||||||
|
<path d="M57.3330367,7.40625 L57.3330367,8.4375 L57.2395996,8.4375 C56.6229143,7.6875 55.4269188,7.0125 53.9132369,7.0125 C50.7550612,7.0125 47.8585095,9.7875 47.8585095,13.36875 C47.8585095,16.9125 50.7550612,19.6875 53.9132369,19.6875 C55.4269188,19.6875 56.6229143,19.0125 57.2395996,18.24375 L57.3330367,18.24375 L57.3330367,19.1625 C57.3330367,21.58125 56.043604,22.875 53.9692992,22.875 C52.268743,22.875 51.2222469,21.65625 50.792436,20.625 L48.3817575,21.6375 C49.0731924,23.325 50.9232481,25.3875 53.9879867,25.3875 C57.2395996,25.3875 59.9866518,23.475 59.9866518,18.7875 L59.9866518,7.40625 L57.3330367,7.40625 Z M54.1561735,17.19375 C52.2500556,17.19375 50.6429366,15.58125 50.6429366,13.36875 C50.6429366,11.1375 52.2500556,9.50625 54.1561735,9.50625 C56.043604,9.50625 57.519911,11.1375 57.519911,13.36875 C57.5385984,15.6 56.043604,17.19375 54.1561735,17.19375 Z" id="XMLID_15_" fill="#4285F4" fill-rule="nonzero"></path>
|
||||||
|
<polygon id="XMLID_14_" fill="#34A853" fill-rule="nonzero" points="61.8927697 0.69375 64.6585095 0.69375 64.6585095 19.3125 61.8927697 19.3125"></polygon>
|
||||||
|
<path d="M72.2642937,17.19375 C70.8440489,17.19375 69.8536151,16.5375 69.1995551,15.28125 L77.6462736,11.775 L77.3659622,11.0625 C76.8427141,9.6375 75.2355951,7.03125 71.9652948,7.03125 C68.7136819,7.03125 66.0040044,9.6 66.0040044,13.36875 C66.0040044,16.9125 68.676307,19.70625 72.2642937,19.70625 C75.1608454,19.70625 76.8240267,17.925 77.5341491,16.89375 L75.3850945,15.45 C74.6562848,16.5 73.6845384,17.19375 72.2642937,17.19375 Z M72.0587319,9.46875 C73.1612903,9.46875 74.0956618,10.0125 74.3946607,10.81875 L68.7510567,13.1625 C68.676307,10.725 70.6384872,9.46875 72.0587319,9.46875 Z" id="XMLID_7_" fill="#EA4335" fill-rule="nonzero"></path>
|
||||||
|
<rect id="XMLID_4_" x="0" y="0" width="77.7397108" height="25.5"></rect>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><defs><radialGradient id="A" cx="-254.817" cy="705.837" gradientTransform="matrix(2.827,1.6322,-1.6322,2.827,1967.2161,-1760.7166)" gradientUnits="userSpaceOnUse" r="82.973"><stop offset="0" stop-color="#ffd24d"/><stop offset="1" stop-color="#f6c338"/></radialGradient><radialGradient id="B" cx="-254.82" cy="705.836" gradientTransform="matrix(2.827,1.6322,-1.6322,2.827,1967.2161,-1760.7166)" gradientUnits="userSpaceOnUse" r="82.978"><stop offset="0" stop-color="#4387fd"/><stop offset=".65" stop-color="#3078f0"/><stop offset=".91" stop-color="#2b72ea"/><stop offset="1" stop-color="#286ee6"/></radialGradient></defs><g transform="matrix(.460432 0 0 .460432 57.509647 126.82041)"><path d="M-100.704-145.738l-24.2-41.9 45.3-78.5 24.2 41.9z" fill="#0da960"/><path d="M-100.704-145.738l24.2-41.9h90.6l-24.2 41.9z" fill="url(#B)"/><path d="M14.096-187.638h-48.4l-45.3-78.5h48.4z" fill="url(#A)"/><path d="M-55.404-187.638h-21.1l10.5-18.3-34.7 60.2z" fill="#2d6fdd"/><path d="M-34.304-187.638h48.4l-58.9-18.3z" fill="#e5b93c"/><path d="M-66.004-205.938l10.6-18.3-24.2-41.9z" fill="#0c9b57"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 308.4 352.9" width="2185" height="2500"><style>.st0{fill:#1768a4}.st1{fill:#3e94d1}.st2{fill:#61cbee}.st3{fill:#fff}</style><path class="st0" d="M0 7.6C0 4.9 1.4 2.4 3.7 1 6.1-.3 9-.3 11.3 1l293.2 167.6c2.3 1.3 3.8 3.8 3.8 6.5s-1.4 5.2-3.8 6.5L12.9 351.8c-2.3 1.4-5.2 1.4-7.6 0-2.3-1.3-3.8-3.8-3.8-6.5L0 7.6z"/><path class="st1" d="M103.4 57.4c0-.8-.1-1.5-.3-1.5H53.6c-.2 0-.3.7-.3 1.5v267c0 .8.1 1.5.3 1.5h3.7l46-26.9V57.4z"/><image width="683" height="1064" xlink:href="28CA37E5.png" transform="matrix(.24 0 0 .24 96.35 48.834)" overflow="visible" opacity=".44"/><path class="st2" d="M255.1 210.5V57.4c0-.8-.5-1.5-1-1.5h-33.9v41.5l-14.7-7.1-15.7 7.1V55.8h-85.5c-.6 0-1 .7-1 1.5V299l151.8-88.5z"/><g><image width="177" height="223" xlink:href="28CA37E7.png" transform="matrix(.24 0 0 .24 184.835 50.834)" overflow="visible" opacity=".44"/><path class="st3" d="M205.5 90.2l14.7 7.1V55.8h-30.4v41.5z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1020 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="134 307.5 32 32" width="64" height="64"><g transform="matrix(.181794 0 0 .181794 111.21426 251.37762)"><linearGradient y2="434.291" x2="100.359" y1="319.454" x1="215.196" gradientUnits="userSpaceOnUse" id="A"><stop offset="0" stop-color="#00a0ff"/><stop offset=".007" stop-color="#00a1ff"/><stop offset=".26" stop-color="#00beff"/><stop offset=".512" stop-color="#00d2ff"/><stop offset=".76" stop-color="#00dfff"/><stop offset="1" stop-color="#00e3ff"/></linearGradient><path d="M137.5 311.5c-2 2.1-3.2 5.4-3.2 9.6v151.3c0 4.2 1.2 7.5 3.2 9.6l.5.5 84.8-84.8v-2L138 310.9z" fill="url(#A)"/><linearGradient y2="396.75" x2="132.007" y1="396.75" x1="297.578" gradientUnits="userSpaceOnUse" id="B"><stop offset="0" stop-color="#ffe000"/><stop offset=".409" stop-color="#ffbd00"/><stop offset=".775" stop-color="orange"/><stop offset="1" stop-color="#ff9c00"/></linearGradient><path d="M251 426l-28.2-28.3v-2l28.3-28.3.6.4 33.5 19c9.6 5.4 9.6 14.3 0 19.8l-33.5 19z" fill="url(#B)"/><linearGradient y2="568.208" x2="80.242" y1="412.481" x1="235.969" gradientUnits="userSpaceOnUse" id="C"><stop offset="0" stop-color="#ff3a44"/><stop offset="1" stop-color="#c31162"/></linearGradient><path d="M251.7 425.6l-28.9-28.9-85.3 85.3c3.1 3.3 8.4 3.8 14.2.4l100-56.8" fill="url(#C)"/><linearGradient y2="330.609" x2="185.559" y1="261.07" x1="116.02" gradientUnits="userSpaceOnUse" id="D"><stop offset="0" stop-color="#32a071"/><stop offset=".069" stop-color="#2da771"/><stop offset=".476" stop-color="#15cf74"/><stop offset=".801" stop-color="#06e775"/><stop offset="1" stop-color="#00f076"/></linearGradient><path d="M251.7 367.9l-100-56.8c-5.9-3.3-11.1-2.9-14.2.4l85.3 85.3z" fill="url(#D)"/><path d="M251 425l-99.3 56.4c-5.6 3.2-10.5 3-13.7.1l-.5.5.5.5c3.2 2.9 8.2 3.1 13.7-.1l100-56.8z" opacity=".2"/><path d="M137.5 481c-2-2.1-3.2-5.4-3.2-9.6v1c0 4.2 1.2 7.5 3.2 9.6l.5-.5zm147.6-75.4L251 425l.6.6 33.5-19c4.8-2.7 7.2-6.3 7.2-9.9-.4 3.3-2.8 6.4-7.2 8.9z" opacity=".12"/><path d="M151.7 312l133.4 75.8c4.3 2.5 6.8 5.6 7.2 8.9 0-3.6-2.4-7.2-7.2-9.9L151.7 311c-9.6-5.4-17.4-.9-17.4 10v1c0-10.9 7.8-15.4 17.4-10z" opacity=".25" fill="#fff"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 16.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="388px" height="86px" viewBox="0 0 388 86" enable-background="new 0 0 388 86" xml:space="preserve">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" fill="#FF9900" d="M134.49,69.526c-12.391,9.145-30.354,14.007-45.822,14.007
|
||||||
|
c-21.679,0-41.201-8.014-55.973-21.353c-1.16-1.048-0.125-2.479,1.269-1.667c15.938,9.274,35.649,14.86,56.006,14.86
|
||||||
|
c13.732,0,28.828-2.851,42.719-8.742C134.783,65.744,136.54,68.013,134.49,69.526z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" fill="#FF9900" d="M139.646,63.641c-1.585-2.029-10.475-0.962-14.47-0.483
|
||||||
|
c-1.21,0.146-1.398-0.912-0.308-1.678c7.093-4.981,18.714-3.543,20.065-1.875c1.361,1.683-0.357,13.334-7.004,18.894
|
||||||
|
c-1.022,0.855-1.995,0.399-1.542-0.73C137.884,74.033,141.232,65.671,139.646,63.641z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M125.458,26.289v-4.841c0.004-0.737,0.558-1.228,1.228-1.226l21.695-0.002
|
||||||
|
c0.693,0,1.25,0.504,1.25,1.221v4.152c-0.006,0.697-0.593,1.605-1.633,3.047l-11.238,16.045c4.171-0.098,8.585,0.527,12.375,2.658
|
||||||
|
c0.854,0.479,1.083,1.19,1.151,1.888v5.166c0,0.711-0.78,1.535-1.599,1.105c-6.676-3.497-15.538-3.879-22.922,0.044
|
||||||
|
c-0.754,0.401-1.543-0.41-1.543-1.123v-4.909c0-0.786,0.015-2.13,0.808-3.327l13.02-18.679l-11.336-0.001
|
||||||
|
C126.018,27.508,125.462,27.014,125.458,26.289z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M46.321,56.521h-6.601c-0.628-0.042-1.129-0.513-1.181-1.116l0.005-33.874
|
||||||
|
c0-0.678,0.569-1.219,1.273-1.219l6.147-0.002c0.643,0.033,1.159,0.519,1.199,1.138v4.422h0.124
|
||||||
|
c1.602-4.277,4.621-6.273,8.686-6.273c4.128,0,6.716,1.997,8.564,6.273c1.603-4.277,5.237-6.273,9.119-6.273
|
||||||
|
c2.772,0,5.792,1.141,7.64,3.707c2.094,2.852,1.665,6.985,1.665,10.621l-0.006,21.378c0,0.676-0.569,1.219-1.273,1.219H75.09
|
||||||
|
c-0.663-0.043-1.184-0.567-1.184-1.217l-0.002-17.958c0-1.424,0.123-4.989-0.186-6.343c-0.493-2.282-1.971-2.924-3.88-2.924
|
||||||
|
c-1.603,0-3.267,1.069-3.945,2.78c-0.677,1.711-0.616,4.562-0.616,6.487v17.957c0,0.676-0.569,1.219-1.272,1.219h-6.593
|
||||||
|
c-0.663-0.043-1.185-0.567-1.185-1.217l-0.006-17.958c0-3.778,0.616-9.337-4.065-9.337c-4.745,0-4.56,5.417-4.56,9.337
|
||||||
|
l-0.002,17.957C47.595,55.979,47.024,56.521,46.321,56.521z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M168.307,19.597c9.796,0,15.093,8.413,15.093,19.104
|
||||||
|
c0,10.335-5.852,18.534-15.093,18.534c-9.611,0-14.849-8.413-14.849-18.891C153.458,27.794,158.757,19.597,168.307,19.597z
|
||||||
|
M168.367,26.512c-4.867,0-5.174,6.628-5.174,10.763c0,4.134-0.061,12.973,5.114,12.973c5.115,0,5.36-7.128,5.36-11.475
|
||||||
|
c0-2.851-0.123-6.273-0.987-8.983C171.941,27.438,170.461,26.512,168.367,26.512z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.105,56.521h-6.576c-0.661-0.043-1.185-0.567-1.185-1.217l-0.011-33.887
|
||||||
|
c0.056-0.62,0.603-1.105,1.268-1.105l6.122-0.002c0.577,0.03,1.052,0.423,1.173,0.948v5.182h0.125
|
||||||
|
c1.849-4.635,4.435-6.844,8.994-6.844c2.957,0,5.854,1.069,7.701,3.993c1.725,2.708,1.725,7.27,1.725,10.549v21.316
|
||||||
|
c-0.072,0.6-0.612,1.066-1.265,1.066h-6.615c-0.611-0.039-1.104-0.488-1.175-1.066V37.062c0-3.707,0.432-9.125-4.128-9.125
|
||||||
|
c-1.603,0-3.081,1.069-3.82,2.708c-0.925,2.068-1.049,4.134-1.049,6.417v18.24C197.379,55.979,196.809,56.521,196.105,56.521z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.164,40.344c0,2.573,0.061,4.715-1.236,7.002c-1.049,1.856-2.717,3-4.568,3
|
||||||
|
c-2.531,0-4.013-1.93-4.013-4.787c0-5.621,5.042-6.643,9.818-6.643V40.344z M114.817,56.437c-0.436,0.393-1.066,0.418-1.559,0.153
|
||||||
|
c-2.191-1.821-2.584-2.663-3.783-4.397c-3.623,3.69-6.188,4.798-10.882,4.798c-5.556,0-9.879-3.43-9.879-10.288
|
||||||
|
c0-5.357,2.902-9,7.039-10.787c3.58-1.572,8.582-1.857,12.411-2.287v-0.857c0-1.572,0.123-3.428-0.804-4.786
|
||||||
|
c-0.803-1.215-2.346-1.715-3.705-1.715c-2.517,0-4.754,1.29-5.305,3.963c-0.114,0.595-0.548,1.184-1.146,1.214l-6.397-0.693
|
||||||
|
c-0.541-0.122-1.141-0.555-0.986-1.38c1.472-7.76,8.48-10.104,14.76-10.104c3.212,0,7.409,0.855,9.941,3.286
|
||||||
|
c3.212,3,2.902,7.002,2.902,11.36v10.281c0,3.094,1.285,4.45,2.491,6.116c0.422,0.602,0.516,1.314-0.022,1.754
|
||||||
|
c-1.348,1.131-3.743,3.211-5.061,4.384L114.817,56.437z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.64,40.344c0,2.573,0.062,4.715-1.234,7.002c-1.048,1.856-2.717,3-4.569,3
|
||||||
|
c-2.531,0-4.013-1.93-4.013-4.787c0-5.621,5.043-6.643,9.816-6.643V40.344z M28.295,56.437c-0.436,0.393-1.067,0.418-1.559,0.153
|
||||||
|
c-2.193-1.821-2.586-2.663-3.783-4.397c-3.624,3.69-6.189,4.798-10.881,4.798c-5.557,0-9.879-3.43-9.879-10.288
|
||||||
|
c0-5.357,2.901-9,7.039-10.787c3.581-1.572,8.583-1.857,12.409-2.287v-0.857c0-1.572,0.125-3.428-0.801-4.786
|
||||||
|
c-0.804-1.215-2.347-1.715-3.705-1.715c-2.518,0-4.757,1.29-5.305,3.963c-0.115,0.595-0.548,1.184-1.147,1.214l-6.396-0.693
|
||||||
|
C3.745,30.632,3.144,30.2,3.3,29.374C4.77,21.614,11.778,19.27,18.06,19.27c3.211,0,7.41,0.855,9.941,3.286
|
||||||
|
c3.21,3,2.902,7.002,2.902,11.36v10.281c0,3.094,1.284,4.45,2.49,6.116c0.421,0.602,0.517,1.314-0.021,1.754
|
||||||
|
c-1.349,1.131-3.745,3.211-5.062,4.384L28.295,56.437z"/>
|
||||||
|
<path fill="#FF9900" d="M246.028,56.535L230.65,37.891h-0.075v18.644h-5.53V2.877h5.53v30.467h0.075l13.182-12.657h7.196
|
||||||
|
l-15.757,14.627l17.803,21.221H246.028z"/>
|
||||||
|
<path fill="#FF9900" d="M259.511,13.184c-2.121,0-3.863-1.743-3.863-3.865c0-2.123,1.742-3.865,3.863-3.865s3.863,1.742,3.863,3.865
|
||||||
|
C263.374,11.441,261.632,13.184,259.511,13.184z M256.784,56.535V20.687h5.529v35.848H256.784z"/>
|
||||||
|
<path fill="#FF9900" d="M293.392,56.535V34.481c0-4.472-0.757-9.474-6.212-9.474c-4.394,0-8.408,3.107-11.135,5.458v26.071h-5.53
|
||||||
|
V20.687h4.545l0.758,4.32c3.938-3.031,7.803-5.229,12.803-5.229c5.15,0,10.302,3.713,10.302,12.657v24.101H293.392z"/>
|
||||||
|
<path fill="#FF9900" d="M323.313,57.293c-9.999,0-18.408-5.835-18.408-18.189c0-11.14,8.029-19.326,18.483-19.326
|
||||||
|
c2.575,0,4.395,0.227,5.833,0.606V2.877h5.53v51.839C332.555,56.005,328.389,57.293,323.313,57.293z M329.222,25.538
|
||||||
|
c-1.438-0.531-2.727-0.909-5.529-0.909c-7.046,0-13.182,5.533-13.182,14.097c0,8.109,4.47,13.869,13.787,13.869
|
||||||
|
c1.817,0,3.788-0.379,4.924-0.909V25.538z"/>
|
||||||
|
<path fill="#FF9900" d="M349.144,57.293c-4.394,0-6.287-2.804-6.287-6.594V2.877h5.53v45.321c0,3.41,0.833,4.093,2.954,4.093
|
||||||
|
c0.379,0,0.985,0,1.894-0.227l0.683,4.017C352.25,56.914,350.962,57.293,349.144,57.293z"/>
|
||||||
|
<path fill="#FF9900" d="M362.854,39.559c0,6.821,3.939,12.958,11.666,12.958c3.258,0,6.591-1.287,8.712-2.651l1.742,3.94
|
||||||
|
c-2.651,1.819-7.196,3.486-11.136,3.486c-11.438,0-16.894-9.17-16.894-19.402c0-10.383,5.909-18.113,14.545-18.113
|
||||||
|
c9.242,0,14.317,7.352,14.317,18.493c0,0.379,0,0.833,0,1.289H362.854z M371.565,24.477c-5.302,0-8.636,4.699-8.636,10.383h16.894
|
||||||
|
C379.823,29.858,377.021,24.477,371.565,24.477z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0.4 0.4 149 27.700000000000003" xmlns="http://www.w3.org/2000/svg" width="2500" height="459"><g fill="#c0272d"><path d="M96.9.4h3.3v11.8l4.6-5.4h4l-5.3 6.1 5.7 8.1h-3.9l-5-7.2h-.1V21h-3.3z"/><path d="M115.2 19c2.4 0 2.7-2.4 2.7-5.1 0-2.6-.4-5.1-2.7-5.1-2.4 0-2.7 2.4-2.7 5.1-.1 2.6.3 5.1 2.7 5.1m-6.3-5.1c0-4.6 2.2-7.4 6.3-7.4s6.2 2.8 6.2 7.4-2.2 7.4-6.2 7.4c-4.1 0-6.3-2.8-6.3-7.4M129.2 19c2.1 0 2.5-2.8 2.5-5.1 0-2.6-.3-5.3-2.5-5.3-2 0-2.6 2.9-2.6 5.3-.1 3.5.5 5.1 2.6 5.1m-2.7-10.5c.5-.7 1.6-2 3.7-2 3.4 0 4.9 2.9 4.9 7.4 0 4-1.4 7.4-6 7.4-3.7 0-5.9-2.1-5.9-6.3V.4h3.3zM143.1 19c2.4 0 2.7-2.4 2.7-5.1 0-2.6-.4-5.1-2.7-5.1-2.4 0-2.7 2.4-2.7 5.1-.1 2.6.3 5.1 2.7 5.1m-6.3-5.1c0-4.6 2.2-7.4 6.3-7.4s6.3 2.8 6.3 7.4-2.2 7.4-6.3 7.4-6.3-2.8-6.3-7.4M73.6 23.8h-55l4.4 4.3zM23.7 6.1v.7c-1-.7-1.9-1-3.2-1-3.9 0-6.8 3.5-6.8 7.8s2.9 7.8 6.8 7.8c1.3 0 2.2-.4 3.2-1v.6h3.4V6.1zm-3.2 11.6c-1.9 0-3.3-1.9-3.3-4.2 0-2.4 1.4-4.2 3.3-4.2s3.2 1.9 3.2 4.2c0 2.4-1.3 4.2-3.2 4.2M50.8 6.1v8.8c0 1.6-1.1 3-2.8 3-1.6 0-2.8-1.4-2.8-3V6.1h-3.4v8.8c0 3.6 2.5 6.5 6.1 6.5 1.7 0 2.9-.9 2.9-.9v.5h3.4V6.1zM82.4 21v-8.8c0-1.6 1.1-3 2.8-3 1.6 0 2.8 1.4 2.8 3V21h3.4v-8.8c0-3.6-2.5-6.5-6.1-6.5-1.7 0-2.9.9-2.9.9v-.5H79V21z"/><path d="M4 21v-5.8h2.5l4.3 5.8h4.4L10 14c1.6-1.2 2.7-3.1 2.7-5.2 0-3.5-2.9-6.4-6.4-6.4H.4V21zM4 5.9h2.3c1.6 0 2.9 1.3 2.9 2.9s-1.3 2.9-2.9 2.9H4zM63.3 17.4c-.3.2-.7.4-1.1.4-.6 0-1.6-.4-1.6-1.8V9.6h2.9V6.1h-2.9V2.3h-3.4V6h-1.8v3.5h1.8V16c0 3.3 2.5 5.3 5 5.3.9 0 2.2-.3 3.3-.9zM35.5 13l5.8-6.9h-4.7l-4.1 5.1V1.1H29V21h3.5v-6.2l5 6.2h4.7z"/><path d="M71.2 5.7c-3.9 0-6.7 3.4-6.7 7.8 0 4.6 3.5 7.8 7.1 7.8 1.8 0 4.1-.6 6-3.3l-3-1.7c-2.3 3.4-6.2 1.7-6.6-1.7h9.8c.7-5.4-2.8-8.9-6.6-8.9m2.9 5.9H68c.7-3.5 5.5-3.7 6.1 0"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,438 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg version="1.1" viewBox="0 0 216 256" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>Tux</title>
|
||||||
|
<defs id="tux_fx">
|
||||||
|
<linearGradient id="gradient_belly_shadow">
|
||||||
|
<stop offset="0" stop-color="#000000"/>
|
||||||
|
<stop offset="1" stop-color="#000000" stop-opacity="0.25"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_wing_tip_right_shadow">
|
||||||
|
<stop offset="0" stop-color="#110800"/>
|
||||||
|
<stop offset="0.59" stop-color="#a65a00" stop-opacity="0.8"/>
|
||||||
|
<stop offset="1" stop-color="#ff921e" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_wing_tip_right_glare_1">
|
||||||
|
<stop offset="0" stop-color="#7c7c7c"/>
|
||||||
|
<stop offset="1" stop-color="#7c7c7c" stop-opacity="0.33"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_wing_tip_right_glare_2">
|
||||||
|
<stop offset="0" stop-color="#7c7c7c"/>
|
||||||
|
<stop offset="1" stop-color="#7c7c7c" stop-opacity="0.33"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_foot_left_layer_1">
|
||||||
|
<stop offset="0" stop-color="#b98309"/>
|
||||||
|
<stop offset="1" stop-color="#382605"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_foot_left_glare">
|
||||||
|
<stop offset="0" stop-color="#ebc40c"/>
|
||||||
|
<stop offset="1" stop-color="#ebc40c" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_foot_right_shadow">
|
||||||
|
<stop offset="0" stop-color="#000000"/>
|
||||||
|
<stop offset="1" stop-color="#000000" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_foot_right_layer_1">
|
||||||
|
<stop offset="0" stop-color="#3e2a06"/>
|
||||||
|
<stop offset="1" stop-color="#ad780a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_foot_right_glare">
|
||||||
|
<stop offset="0" stop-color="#f3cd0c"/>
|
||||||
|
<stop offset="1" stop-color="#f3cd0c" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_eyeball">
|
||||||
|
<stop offset="0" stop-color="#fefefc"/>
|
||||||
|
<stop offset="0.75" stop-color="#fefefc"/>
|
||||||
|
<stop offset="1" stop-color="#d4d4d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_pupil_left_glare">
|
||||||
|
<stop offset="0" stop-color="#757574" stop-opacity="0"/>
|
||||||
|
<stop offset="0.25" stop-color="#757574"/>
|
||||||
|
<stop offset="0.5" stop-color="#757574"/>
|
||||||
|
<stop offset="1" stop-color="#757574" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_pupil_right_glare_2">
|
||||||
|
<stop offset="0" stop-color="#949494" stop-opacity="0.39"/>
|
||||||
|
<stop offset="0.5" stop-color="#949494"/>
|
||||||
|
<stop offset="1" stop-color="#949494" stop-opacity="0.39"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_eyelid_left">
|
||||||
|
<stop offset="0" stop-color="#c8c8c8"/>
|
||||||
|
<stop offset="1" stop-color="#797978"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_eyelid_right">
|
||||||
|
<stop offset="0" stop-color="#747474"/>
|
||||||
|
<stop offset="0.13" stop-color="#8c8c8c"/>
|
||||||
|
<stop offset="0.25" stop-color="#a4a4a4"/>
|
||||||
|
<stop offset="0.5" stop-color="#d4d4d4"/>
|
||||||
|
<stop offset="0.62" stop-color="#d4d4d4"/>
|
||||||
|
<stop offset="1" stop-color="#7c7c7c"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_eyebrow">
|
||||||
|
<stop offset="0" stop-color="#646464" stop-opacity="0"/>
|
||||||
|
<stop offset="0.31" stop-color="#646464" stop-opacity="0.58"/>
|
||||||
|
<stop offset="0.47" stop-color="#646464"/>
|
||||||
|
<stop offset="0.73" stop-color="#646464" stop-opacity="0.26"/>
|
||||||
|
<stop offset="1" stop-color="#646464" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_beak_base">
|
||||||
|
<stop offset="0" stop-color="#020204"/>
|
||||||
|
<stop offset="0.73" stop-color="#020204"/>
|
||||||
|
<stop offset="1" stop-color="#5c5c5c"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_mandible_lower">
|
||||||
|
<stop offset="0" stop-color="#d2940a"/>
|
||||||
|
<stop offset="0.75" stop-color="#d89c08"/>
|
||||||
|
<stop offset="0.87" stop-color="#b67e07"/>
|
||||||
|
<stop offset="1" stop-color="#946106"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_mandible_upper">
|
||||||
|
<stop offset="0" stop-color="#ad780a"/>
|
||||||
|
<stop offset="0.12" stop-color="#d89e08"/>
|
||||||
|
<stop offset="0.25" stop-color="#edb80b"/>
|
||||||
|
<stop offset="0.39" stop-color="#ebc80d"/>
|
||||||
|
<stop offset="0.53" stop-color="#f5d838"/>
|
||||||
|
<stop offset="0.77" stop-color="#f6d811"/>
|
||||||
|
<stop offset="1" stop-color="#f5cd31"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_nares">
|
||||||
|
<stop offset="0" stop-color="#3a2903"/>
|
||||||
|
<stop offset="0.55" stop-color="#735208"/>
|
||||||
|
<stop offset="1" stop-color="#ac8c04"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradient_beak_corner">
|
||||||
|
<stop offset="0" stop-color="#f5ce2d"/>
|
||||||
|
<stop offset="1" stop-color="#d79b08"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="fill_belly_shadow_left" href="#gradient_belly_shadow" xlink:href="#gradient_belly_shadow"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(61.18,121.19) scale(19,18)"/>
|
||||||
|
<radialGradient id="fill_belly_shadow_right" href="#gradient_belly_shadow" xlink:href="#gradient_belly_shadow"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(125.74,131.6) scale(23.6,18)"/>
|
||||||
|
<radialGradient id="fill_belly_shadow_middle" href="#gradient_belly_shadow" xlink:href="#gradient_belly_shadow"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(94.21,127.47) scale(9.35,10)"/>
|
||||||
|
<linearGradient id="fill_foot_left_base" href="#gradient_foot_left_layer_1" xlink:href="#gradient_foot_left_layer_1"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="23.18" y1="193.01" x2="64.31" y2="262.02"/>
|
||||||
|
<linearGradient id="fill_foot_left_glare" href="#gradient_foot_left_glare" xlink:href="#gradient_foot_left_glare"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="64.47" y1="210.83" x2="77.41" y2="235.21"/>
|
||||||
|
<linearGradient id="fill_foot_right_shadow" href="#gradient_foot_right_shadow" xlink:href="#gradient_foot_right_shadow"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="146.93" y1="211.96" x2="150.2" y2="235.73"/>
|
||||||
|
<linearGradient id="fill_foot_right_base" href="#gradient_foot_right_layer_1" xlink:href="#gradient_foot_right_layer_1"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="151.5" y1="253.02" x2="192.94" y2="185.84"/>
|
||||||
|
<linearGradient id="fill_foot_right_glare" href="#gradient_foot_right_glare" xlink:href="#gradient_foot_right_glare"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="162.81" y1="180.67" x2="161.59" y2="191.64"/>
|
||||||
|
<radialGradient id="fill_wing_tip_right_shadow_lower" href="#gradient_wing_tip_right_shadow" xlink:href="#gradient_wing_tip_right_shadow"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(169.71,194.53) rotate(15) scale(19.66,20.64)"/>
|
||||||
|
<radialGradient id="fill_wing_tip_right_shadow_upper" href="#gradient_wing_tip_right_shadow" xlink:href="#gradient_wing_tip_right_shadow"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(169.71,189.89) rotate(-2.42) scale(19.74,14.86)"/>
|
||||||
|
<radialGradient id="fill_wing_tip_right_glare_1" href="#gradient_wing_tip_right_glare_1" xlink:href="#gradient_wing_tip_right_glare_1"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(184.65,176.62) rotate(23.5) scale(6.95,3.21)"/>
|
||||||
|
<linearGradient id="fill_wing_tip_right_glare_2" href="#gradient_wing_tip_right_glare_2" xlink:href="#gradient_wing_tip_right_glare_2"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="165.69" y1="173.58" x2="168.27" y2="173.47"/>
|
||||||
|
<radialGradient id="fill_eyeball_left" href="#gradient_eyeball" xlink:href="#gradient_eyeball"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(86.49,51.41) rotate(-0.6) scale(10.24,15.68)"/>
|
||||||
|
<linearGradient id="fill_pupil_left_glare" href="#gradient_pupil_left_glare" xlink:href="#gradient_pupil_left_glare"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="84.29" y1="46.64" x2="89.32" y2="55.63"/>
|
||||||
|
<radialGradient id="fill_eyelid_left" href="#gradient_eyelid_left" xlink:href="#gradient_eyelid_left"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(84.89,43.74) rotate(-9.35) scale(6.25,5.77)"/>
|
||||||
|
<linearGradient id="fill_eyebrow_left" href="#gradient_eyebrow" xlink:href="#gradient_eyebrow"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="83.59" y1="32.51" x2="94.48" y2="43.63"/>
|
||||||
|
<radialGradient id="fill_eyeball_right" href="#gradient_eyeball" xlink:href="#gradient_eyeball"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(118.06,51.41) rotate(-1.8) scale(13.64,15.68)"/>
|
||||||
|
<linearGradient id="fill_pupil_right_glare" href="#gradient_pupil_right_glare_2" xlink:href="#gradient_pupil_right_glare_2"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="117.87" y1="47.25" x2="123.66" y2="54.11"/>
|
||||||
|
<linearGradient id="fill_eyelid_right" href="#gradient_eyelid_right" xlink:href="#gradient_eyelid_right"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="112.9" y1="36.23" x2="131.32" y2="47.01"/>
|
||||||
|
<linearGradient id="fill_eyebrow_right" href="#gradient_eyebrow" xlink:href="#gradient_eyebrow"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="119.16" y1="31.56" x2="131.42" y2="43.14"/>
|
||||||
|
<radialGradient id="fill_beak_base" href="#gradient_beak_base" xlink:href="#gradient_beak_base"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(97.64,60.12) rotate(-36) scale(11.44,10.38)"/>
|
||||||
|
<radialGradient id="fill_mandible_lower_base" href="#gradient_mandible_lower" xlink:href="#gradient_mandible_lower"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(109.77,70.61) rotate(-22.4) scale(27.15,19.07)"/>
|
||||||
|
<linearGradient id="fill_mandible_upper_base" href="#gradient_mandible_upper" xlink:href="#gradient_mandible_upper"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="78.09" y1="69.26" x2="126.77" y2="68.88"/>
|
||||||
|
<radialGradient id="fill_naris_left" href="#gradient_nares" xlink:href="#gradient_nares"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(92.11,59.88) scale(1.32,1.42)"/>
|
||||||
|
<radialGradient id="fill_naris_right" href="#gradient_nares" xlink:href="#gradient_nares"
|
||||||
|
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(104.65,59.7) scale(2.78,1.62)"/>
|
||||||
|
<linearGradient id="fill_beak_corner" href="#gradient_beak_corner" xlink:href="#gradient_beak_corner"
|
||||||
|
gradientUnits="userSpaceOnUse" x1="126.74" y1="67.49" x2="126.74" y2="71.09"/>
|
||||||
|
<filter id="blur_belly_shadow_left">
|
||||||
|
<feGaussianBlur stdDeviation="0.64 0.55"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_belly_shadow_right">
|
||||||
|
<feGaussianBlur stdDeviation="0.98"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_belly_shadow_middle">
|
||||||
|
<feGaussianBlur stdDeviation="0.68"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_belly_shadow_lower" x="-0.8" width="2.6" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="1.25"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_belly_glare" x="-0.8" width="2.6" y="-0.5" height="2">
|
||||||
|
<feGaussianBlur stdDeviation="1.78 2.19"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_head_glare" x="-0.3" width="1.6" y="-0.3" height="1.6">
|
||||||
|
<feGaussianBlur stdDeviation="1.73"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_neck_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="0.78"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_wing_left_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="0.98"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_wing_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="1.19 1.17"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_foot_left_layer_1" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="3.38"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_foot_left_layer_2">
|
||||||
|
<feGaussianBlur stdDeviation="2.1 2.06"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_foot_left_glare">
|
||||||
|
<feGaussianBlur stdDeviation="0.32"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_foot_right_shadow">
|
||||||
|
<feGaussianBlur stdDeviation="1.95 1.9"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_foot_right_layer_1" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="4.12"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_foot_right_layer_2" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="3.12 3.37"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_foot_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="0.41"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_wing_tip_right_shadow_lower" x="-0.3" width="1.6" y="-0.3" height="1.6">
|
||||||
|
<feGaussianBlur stdDeviation="2.45"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_wing_tip_right_shadow_upper" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="1.12 0.81"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_wing_tip_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="0.88"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_pupil_left_glare" x="-0.3" width="1.6" y="-0.3" height="1.6">
|
||||||
|
<feGaussianBlur stdDeviation="0.44"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_eyebrow_left">
|
||||||
|
<feGaussianBlur stdDeviation="0.12"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_pupil_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="0.45"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_eyebrow_right">
|
||||||
|
<feGaussianBlur stdDeviation="0.13"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_beak_shadow_lower" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="1.75"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_beak_shadow_upper">
|
||||||
|
<feGaussianBlur stdDeviation="0.8 0.74"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_mandible_lower_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="0.77"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_mandible_upper_shadow">
|
||||||
|
<feGaussianBlur stdDeviation="0.65"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_mandible_upper_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="0.73"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_naris_left" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="0.1"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_naris_right">
|
||||||
|
<feGaussianBlur stdDeviation="0.1"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="blur_beak_corner" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||||
|
<feGaussianBlur stdDeviation="0.23"/>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="clip_body">
|
||||||
|
<use href="#body_base" xlink:href="#body_base"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_wing_left">
|
||||||
|
<use href="#wing_left_base" xlink:href="#wing_left_base"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_wing_right">
|
||||||
|
<use href="#wing_right_base" xlink:href="#wing_right_base"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_foot_left">
|
||||||
|
<use href="#foot_left_base" xlink:href="#foot_left_base"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_foot_right">
|
||||||
|
<use href="#foot_right_base" xlink:href="#foot_right_base"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_wing_tip_right">
|
||||||
|
<use href="#wing_tip_right_base" xlink:href="#wing_tip_right_base"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_eye_left">
|
||||||
|
<use href="#eyeball_left" xlink:href="#eyeball_left"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_pupil_left">
|
||||||
|
<use href="#pupil_left_base" xlink:href="#pupil_left_base"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_eye_right">
|
||||||
|
<use href="#eyeball_right" xlink:href="#eyeball_right"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_pupil_right">
|
||||||
|
<use href="#pupil_right_base" xlink:href="#pupil_right_base"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_mandible_lower">
|
||||||
|
<use href="#mandible_lower_base" xlink:href="#mandible_lower_base"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_mandible_upper">
|
||||||
|
<use href="#mandible_upper_base" xlink:href="#mandible_upper_base"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip_beak">
|
||||||
|
<use href="#mandible_lower_base" xlink:href="#mandible_lower_base"/>
|
||||||
|
<use href="#mandible_upper_base" xlink:href="#mandible_upper_base"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="tux">
|
||||||
|
<g id="body">
|
||||||
|
<path id="body_base" fill="#020204"
|
||||||
|
d="m 106.95,0 c -6,0 -12.02,1.18 -17.46,4.12 -5.78,3.11 -10.52,8.09 -13.43,13.97 -2.92,5.88 -4.06,12.16 -4.24,19.08 -0.33,13.14 0.3,26.92 1.29,39.41 0.26,3.8 0.74,6.02 0.25,9.93 -1.62,8.3 -8.88,13.88 -12.76,21.17 -4.27,8.04 -6.07,17.13 -9.29,25.65 -2.95,7.79 -7.09,15.1 -9.88,22.95 -3.91,10.97 -5.08,23.03 -2.5,34.39 1.97,8.66 6.08,16.78 11.62,23.73 -0.8,1.44 -1.58,2.91 -2.4,4.34 -2.57,4.43 -5.71,8.64 -7.17,13.55 -0.73,2.45 -1.02,5.07 -0.55,7.59 0.47,2.52 1.75,4.93 3.75,6.53 1.31,1.04 2.9,1.72 4.53,2.1 1.63,0.37 3.32,0.46 5,0.43 6.37,-0.14 12.55,-2.07 18.71,-3.69 3.66,-0.96 7.34,-1.81 11.03,-2.58 13.14,-2.69 27.8,-1.61 39.99,0.15 4.13,0.63 8.23,1.44 12.29,2.43 6.36,1.54 12.69,3.5 19.23,3.69 1.72,0.05 3.46,-0.03 5.14,-0.4 1.68,-0.38 3.31,-1.06 4.65,-2.13 2.01,-1.6 3.29,-4.02 3.76,-6.54 0.47,-2.52 0.18,-5.15 -0.56,-7.61 -1.48,-4.92 -4.65,-9.11 -7.27,-13.52 -1.04,-1.75 -2,-3.53 -3.03,-5.28 7.9,-8.87 14.26,-19.13 17.94,-30.4 4.01,-12.3 4.75,-25.55 3.06,-38.38 -1.69,-12.83 -5.76,-25.27 -11.11,-37.05 -6.72,-14.76 -12.37,-20.1 -16.47,-33.07 -4.42,-14.02 -0.77,-30.61 -4.06,-43.32 -1.17,-4.32 -3.04,-8.45 -5.45,-12.23 -2.82,-4.43 -6.4,-8.39 -10.65,-11.47 -6.78,-4.92 -15.3,-7.54 -23.96,-7.54 z"/>
|
||||||
|
<path id="belly" fill="#fdfdfb"
|
||||||
|
d="m 83.13,74 c -0.9,1.13 -1.48,2.49 -1.84,3.89 -0.35,1.4 -0.48,2.85 -0.54,4.3 -0.11,2.89 0.07,5.83 -0.7,8.62 -0.82,2.98 -2.65,5.57 -4.44,8.08 -3.11,4.36 -6.25,8.84 -7.78,13.97 -0.93,3.1 -1.24,6.39 -0.91,9.62 -3.47,5.1 -6.48,10.53 -8.98,16.18 -3.78,8.57 -6.37,17.69 -7.28,27.01 -1.12,11.41 0.34,23.15 4.85,33.69 3.25,7.63 8.11,14.6 14.38,20.04 3.18,2.76 6.72,5.11 10.5,6.97 13.11,6.45 29.31,6.46 42.2,-0.41 6.74,-3.59 12.43,-8.84 17.91,-14.15 3.3,-3.2 6.59,-6.48 9.11,-10.32 4.85,-7.41 6.54,-16.41 7.59,-25.2 1.83,-15.36 1.89,-31.6 -4.85,-45.53 -2.32,-4.8 -5.41,-9.22 -9.12,-13.05 -0.98,-6.7 -2.93,-13.27 -5.76,-19.42 -2.05,-4.45 -4.54,-8.68 -6.44,-13.18 -0.78,-1.85 -1.46,-3.75 -2.32,-5.56 -0.87,-1.81 -1.93,-3.55 -3.39,-4.94 -1.48,-1.42 -3.33,-2.43 -5.28,-3.07 -1.95,-0.65 -4.01,-0.94 -6.06,-1.04 -4.11,-0.21 -8.22,0.33 -12.33,0.16 -3.27,-0.13 -6.53,-0.7 -9.8,-0.51 -1.63,0.1 -3.26,0.39 -4.78,1.01 -1.52,0.61 -2.92,1.56 -3.94,2.84 z"/>
|
||||||
|
<g id="body_self_shadows">
|
||||||
|
<path id="belly_shadow_left" opacity="0.25" fill="url(#fill_belly_shadow_left)" filter="url(#blur_belly_shadow_left)" clip-path="url(#clip_body)"
|
||||||
|
d="m 68.67,115.18 c 0.87,1.31 -0.55,5.84 19.86,2.94 0,0 -3.59,0.39 -7.12,1.21 -5.49,1.84 -10.27,3.89 -13.97,6.61 -3.65,2.7 -6.33,6.21 -9.68,9.22 0,0 5.43,-9.92 6.78,-12.91 1.36,-2.99 -0.22,-2.85 0.85,-7.25 1.07,-4.4 3.69,-8.63 3.69,-8.63 0,0 -2.14,6.22 -0.41,8.81 z"/>
|
||||||
|
<path id="belly_shadow_right" opacity="0.42" fill="url(#fill_belly_shadow_right)" filter="url(#blur_belly_shadow_right)" clip-path="url(#clip_body)"
|
||||||
|
d="m 134.28,113.99 c -4.16,2.9 -6.6,2.56 -11.64,3.12 -5.05,0.57 -18.7,0.36 -18.7,0.36 0,0 1.97,-0.03 6.36,0.78 4.38,0.82 13.31,1.6 18.34,3.51 5.04,1.92 6.87,2.47 9.93,4.4 4.35,2.75 7.55,7.06 11.71,10.08 0,0 0.2,-4 -1.48,-6.99 -1.68,-2.99 -6.2,-7.7 -7.53,-12.1 -1.32,-4.4 -1.96,-13.04 -1.96,-13.04 0,0 -0.88,6.99 -5.03,9.88 z"/>
|
||||||
|
<path id="belly_shadow_middle" opacity="0.2" fill="url(#fill_belly_shadow_middle)" filter="url(#blur_belly_shadow_middle)" clip-path="url(#clip_body)"
|
||||||
|
d="m 95.17,107.81 c -0.16,1.25 -0.36,2.5 -0.6,3.74 -0.12,0.61 -0.26,1.22 -0.48,1.8 -0.23,0.58 -0.56,1.14 -1.02,1.55 -0.41,0.37 -0.9,0.62 -1.4,0.85 -1.94,0.88 -4.01,1.47 -6.12,1.74 0.84,0.06 1.68,0.14 2.53,0.23 0.53,0.06 1.06,0.12 1.57,0.25 0.52,0.14 1.03,0.34 1.46,0.65 0.47,0.35 0.84,0.82 1.12,1.34 0.55,1.02 0.73,2.2 0.83,3.37 0.13,1.48 0.14,2.98 0.03,4.46 0.1,-0.99 0.31,-1.98 0.62,-2.92 0.57,-1.72 1.47,-3.32 2.69,-4.65 0.49,-0.52 1.02,-1.01 1.6,-1.42 1.79,-1.26 4.07,-1.81 6.24,-1.51 -2.21,0.09 -4.44,-0.6 -6.2,-1.93 -0.9,-0.68 -1.68,-1.52 -2.22,-2.5 -0.84,-1.52 -1.08,-3.37 -0.65,-5.05 z"/>
|
||||||
|
<path id="belly_shadow_lower" opacity="0.11" fill="#000000" filter="url(#blur_belly_shadow_lower)" clip-path="url(#clip_body)"
|
||||||
|
d="m 89.85,137.14 c -1.06,4.03 -1.79,8.15 -2.17,12.31 -0.55,5.87 -0.42,11.78 -0.74,17.67 -0.26,4.99 -0.85,10.04 0.02,14.97 0.41,2.35 1.15,4.64 2.2,6.78 0.16,-0.82 0.29,-1.64 0.36,-2.47 0.37,-4 -0.3,-8.01 -0.53,-12.01 -0.4,-7.02 0.57,-14.04 0.97,-21.06 0.3,-5.39 0.27,-10.8 -0.11,-16.19 z"/>
|
||||||
|
</g>
|
||||||
|
<g id="body_glare">
|
||||||
|
<path id="belly_glare" opacity="0.75" fill="#7c7c7c" filter="url(#blur_belly_glare)" clip-path="url(#clip_body)"
|
||||||
|
d="m 160.08,131.23 c 1.03,-0.16 7.34,5.21 6.48,7.21 -0.86,1.99 -2.49,0.79 -3.65,0.8 -1.16,0.02 -4.33,1.46 -4.86,0.55 -0.54,-0.91 1.4,-3.03 2.41,-4.81 0.82,-1.43 -1.4,-3.59 -0.38,-3.75 z"/>
|
||||||
|
<path id="head_glare" fill="#7c7c7c" filter="url(#blur_head_glare)" clip-path="url(#clip_body)"
|
||||||
|
d="m 121.52,11.12 c -2.21,1.56 -1.25,3.51 -0.3,5.46 0.95,1.96 -2.09,7.59 -2.12,7.83 -0.03,0.24 5.98,-2.85 7.62,-4.87 1.94,-2.37 6.83,3.22 6.56,2.37 0.01,-1.52 -9.55,-12.34 -11.76,-10.79 z"/>
|
||||||
|
<path id="neck_glare" fill="#838384" filter="url(#blur_neck_glare)" clip-path="url(#clip_body)"
|
||||||
|
d="m 138.27,76.63 c -1.86,1.7 0.88,4.25 2.17,7.24 0.81,1.86 3.04,4.49 5.2,4.07 1.63,-0.32 2.63,-2.66 2.48,-4.3 -0.3,-3.18 -2.98,-3.93 -4.93,-5.02 -1.54,-0.86 -3.61,-3.18 -4.92,-1.99 z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="wings">
|
||||||
|
<g id="wing_left">
|
||||||
|
<path id="wing_left_base" fill="#020204"
|
||||||
|
d="m 63.98,100.91 c -6.1,6.92 -12.37,13.63 -15.81,21.12 -1.71,3.8 -2.51,7.93 -3.68,11.93 -1.32,4.54 -3.12,8.94 -5.14,13.22 -1.87,3.95 -3.93,7.81 -5.98,11.66 -1.5,2.81 -3.02,5.67 -3.54,8.81 -0.41,2.48 -0.18,5.04 0.46,7.47 0.63,2.43 1.64,4.75 2.79,6.98 4.88,9.55 12.21,17.77 20.89,24.07 3.94,2.85 8.15,5.32 12.58,7.35 2.4,1.09 4.92,2.07 7.56,2.11 1.32,0.03 2.65,-0.19 3.86,-0.72 1.2,-0.53 2.28,-1.38 3,-2.49 0.88,-1.36 1.18,-3.05 1,-4.66 -0.18,-1.61 -0.81,-3.15 -1.65,-4.53 -2.06,-3.38 -5.31,-5.83 -8.44,-8.25 -6.76,-5.23 -13.29,-10.76 -19.55,-16.58 -1.76,-1.65 -3.53,-3.34 -4.76,-5.42 -1.2,-2.02 -1.85,-4.32 -2.29,-6.63 -1.21,-6.33 -0.9,-12.99 1.25,-19.07 0.85,-2.38 1.96,-4.65 3.04,-6.93 1.86,-3.95 3.62,-7.98 6.07,-11.6 3.05,-4.51 7.13,-8.33 9.61,-13.17 2.1,-4.09 2.95,-8.68 3.76,-13.2 0.64,-3.54 1.85,-7 2.47,-10.54 -1.21,2.3 -5.11,6.07 -7.5,9.07 z"/>
|
||||||
|
<path id="wing_left_glare" opacity="0.95" fill="#7c7c7c" filter="url(#blur_wing_left_glare)" clip-path="url(#clip_wing_left)"
|
||||||
|
d="m 56.96,126.1 c -2,1.84 -3.73,3.97 -5.13,6.31 -2.3,3.84 -3.65,8.16 -5.33,12.31 -1.24,3.09 -2.69,6.2 -2.86,9.53 -0.09,1.71 0.16,3.42 0.22,5.13 0.06,1.71 -0.1,3.49 -0.94,4.98 -0.7,1.25 -1.87,2.23 -3.22,2.71 1.83,0.61 3.45,1.79 4.6,3.33 0.96,1.3 1.58,2.81 2.41,4.18 0.68,1.12 1.51,2.16 2.54,2.97 1.02,0.82 2.25,1.4 3.54,1.56 1.79,0.23 3.65,-0.36 4.97,-1.58 -1.66,-15.55 -0.14,-31.42 4.44,-46.37 0.29,-0.94 0.59,-1.89 0.67,-2.87 0.07,-0.99 -0.12,-2.03 -0.72,-2.81 -0.31,-0.42 -0.74,-0.75 -1.23,-0.96 -0.48,-0.2 -1.02,-0.28 -1.54,-0.21 -0.52,0.06 -1.03,0.26 -1.45,0.57 -0.42,0.32 -0.76,0.74 -0.97,1.22 z"/>
|
||||||
|
</g>
|
||||||
|
<g id="wing_right">
|
||||||
|
<path id="wing_right_base" fill="#020204"
|
||||||
|
d="m 162.76,127.12 c 5.24,4.22 8.57,10.59 9.6,17.24 0.8,5.18 0.28,10.51 -0.89,15.62 -1.17,5.12 -2.97,10.06 -4.77,15 -0.71,1.96 -1.43,3.95 -1.71,6.02 -0.29,2.08 -0.11,4.27 0.89,6.11 1.15,2.11 3.29,3.56 5.59,4.24 2.27,0.68 4.72,0.66 7.02,0.09 2.3,-0.57 6.17,-1.31 8.04,-2.77 4.75,-3.69 5.88,-10.1 7.01,-15.72 1.17,-5.87 0.6,-12.02 -0.43,-17.95 -1.41,-8.09 -3.78,-15.99 -6.79,-23.62 -2.22,-5.62 -5.06,-10.98 -8.44,-15.96 -3.32,-4.89 -8.02,-8.7 -11.5,-13.48 -1.21,-1.66 -2.66,-3.38 -3.84,-5.06 -2.56,-3.62 -1.98,-2.94 -3.57,-5.29 -1.15,-1.7 -2.97,-2.28 -4.88,-3.02 -1.92,-0.74 -4.06,-0.96 -6.04,-0.41 -2.6,0.73 -4.73,2.79 -5.86,5.24 -1.13,2.46 -1.33,5.28 -0.89,7.95 0.57,3.44 2.14,6.64 3.92,9.64 2,3.39 4.32,6.66 7.35,9.18 3.16,2.63 6.98,4.37 10.19,6.95 z"/>
|
||||||
|
<path id="wing_right_glare" fill="#838384" filter="url(#blur_wing_right_glare)" clip-path="url(#clip_wing_right)"
|
||||||
|
d="m 150.42,118.99 c 0.42,0.4 0.86,0.81 1.31,1.19 3.22,2.63 4.93,5.58 8.2,8.16 5.34,4.22 10.75,11.5 11.8,18.15 0.82,5.19 -0.26,8.01 -1.58,14.12 -1.32,6.12 -5.06,14.78 -7.09,20.68 -0.8,2.35 1.64,1.38 1.32,3.86 -0.16,1.22 -0.18,2.45 -0.03,3.67 0.02,-0.23 0.03,-0.48 0.06,-0.71 0.39,-3.38 1.42,-6.63 2.55,-9.82 2.17,-6.13 4.66,-12.15 6.38,-18.45 1.72,-6.29 1.53,-10.82 0.63,-16.23 -1.13,-6.81 -5.09,-13.09 -10.69,-17.24 -3.97,-2.93 -8.64,-4.81 -12.86,-7.38 z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="feet">
|
||||||
|
<g id="foot_left">
|
||||||
|
<path id="foot_left_base" fill="url(#fill_foot_left_base)"
|
||||||
|
d="m 34.98,175.33 c 1.38,-0.57 2.93,-0.68 4.39,-0.41 1.47,0.27 2.86,0.91 4.09,1.74 2.47,1.68 4.3,4.12 6.05,6.54 4.03,5.54 7.9,11.2 11.42,17.08 2.85,4.78 5.46,9.71 8.76,14.18 2.15,2.93 4.57,5.64 6.73,8.55 2.16,2.92 4.07,6.08 5.03,9.58 1.25,4.55 0.76,9.56 -1.4,13.75 -1.52,2.95 -3.86,5.48 -6.7,7.19 -2.84,1.71 -5.83,2.47 -9.15,2.47 -5.27,0 -10.42,-2.83 -15.32,-4.78 -9.98,-3.98 -20.82,-5.22 -31.11,-8.32 -3.16,-0.95 -6.27,-2.08 -9.45,-2.95 -1.42,-0.39 -2.85,-0.73 -4.19,-1.34 -1.34,-0.6 -2.59,-1.51 -3.33,-2.77 -0.57,-0.98 -0.8,-2.13 -0.8,-3.26 0,-1.14 0.28,-2.26 0.67,-3.32 0.77,-2.13 2.02,-4.06 2.86,-6.17 1.37,-3.44 1.62,-7.23 1.43,-10.93 -0.18,-3.69 -0.78,-7.36 -1.03,-11.05 -0.12,-1.65 -0.16,-3.32 0.16,-4.95 0.31,-1.62 1.01,-3.21 2.2,-4.35 1.1,-1.06 2.55,-1.69 4.05,-2 1.49,-0.31 3.03,-0.32 4.55,-0.29 1.52,0.03 3.05,0.12 4.57,-0.01 1.52,-0.12 3.05,-0.46 4.37,-1.22 1.26,-0.72 2.29,-1.79 3.14,-2.96 0.85,-1.17 1.54,-2.45 2.25,-3.72 0.7,-1.26 1.43,-2.52 2.36,-3.64 0.92,-1.12 2.06,-2.09 3.4,-2.64 z"/>
|
||||||
|
<path id="foot_left_layer_1" fill="#d99a03" filter="url(#blur_foot_left_layer_1)" clip-path="url(#clip_foot_left)"
|
||||||
|
d="m 37.16,177.7 c 1.25,-0.5 2.67,-0.56 3.98,-0.26 1.32,0.3 2.55,0.94 3.61,1.77 2.14,1.65 3.62,3.97 5.05,6.26 3.42,5.54 6.76,11.15 9.92,16.86 2.4,4.31 4.68,8.7 7.62,12.65 1.95,2.62 4.18,5.03 6.17,7.62 1.99,2.59 3.76,5.41 4.64,8.56 1.14,4.05 0.68,8.54 -1.28,12.26 -1.42,2.68 -3.58,4.96 -6.2,6.48 -2.61,1.52 -5.67,2.28 -8.69,2.14 -4.82,-0.22 -9.23,-2.63 -13.77,-4.26 -8.71,-3.16 -18.14,-3.59 -27.08,-6.05 -3.2,-0.87 -6.32,-2.03 -9.53,-2.84 -1.43,-0.36 -2.88,-0.66 -4.23,-1.23 -1.35,-0.57 -2.62,-1.45 -3.36,-2.72 -0.54,-0.95 -0.76,-2.06 -0.73,-3.15 0.04,-1.09 0.31,-2.17 0.7,-3.19 0.78,-2.04 2,-3.88 2.78,-5.92 1.19,-3.08 1.34,-6.47 1.12,-9.76 -0.22,-3.29 -0.8,-6.56 -1,-9.85 -0.08,-1.48 -0.1,-2.97 0.2,-4.41 0.3,-1.45 0.93,-2.85 1.98,-3.89 1.14,-1.13 2.7,-1.74 4.29,-1.99 1.58,-0.24 3.19,-0.13 4.78,0.01 1.6,0.14 3.2,0.32 4.8,0.23 1.6,-0.1 3.22,-0.49 4.54,-1.39 1.2,-0.81 2.1,-2 2.79,-3.27 0.69,-1.27 1.18,-2.64 1.71,-3.98 0.52,-1.35 1.09,-2.69 1.91,-3.89 0.82,-1.19 1.93,-2.24 3.28,-2.79 z"/>
|
||||||
|
<path id="foot_left_layer_2" fill="#f5bd0c" filter="url(#blur_foot_left_layer_2)" clip-path="url(#clip_foot_left)"
|
||||||
|
d="m 35.99,174.57 c 1.22,-0.6 2.65,-0.72 3.98,-0.45 1.33,0.27 2.57,0.92 3.62,1.77 2.09,1.7 3.43,4.13 4.67,6.51 2.84,5.46 5.5,11.04 8.9,16.19 2.48,3.73 5.33,7.2 7.83,10.92 3.39,5.03 6.15,10.57 7.29,16.5 0.76,4 0.74,8.31 -1.18,11.9 -1.27,2.37 -3.32,4.31 -5.75,5.52 -2.42,1.22 -5.21,1.71 -7.92,1.47 -4.27,-0.37 -8.14,-2.47 -12.16,-3.94 -7.13,-2.59 -14.84,-3.22 -22.18,-5.18 -3.09,-0.82 -6.13,-1.89 -9.26,-2.54 -1.39,-0.29 -2.8,-0.5 -4.12,-1 -1.32,-0.5 -2.57,-1.33 -3.25,-2.55 -0.47,-0.86 -0.63,-1.86 -0.56,-2.84 0.07,-0.97 0.36,-1.92 0.74,-2.83 0.77,-1.8 1.9,-3.46 2.49,-5.32 0.88,-2.75 0.52,-5.72 -0.14,-8.53 -0.65,-2.8 -1.6,-5.55 -1.89,-8.41 -0.13,-1.27 -0.13,-2.57 0.17,-3.82 0.29,-1.25 0.88,-2.45 1.81,-3.34 1.2,-1.15 2.88,-1.73 4.56,-1.89 1.67,-0.16 3.35,0.06 5.01,0.3 1.66,0.24 3.34,0.5 5.01,0.42 1.68,-0.07 3.39,-0.51 4.7,-1.54 1.3,-1.02 2.12,-2.53 2.59,-4.09 0.47,-1.57 0.62,-3.2 0.81,-4.82 0.19,-1.62 0.43,-3.26 1.06,-4.77 0.63,-1.51 1.69,-2.9 3.17,-3.64 z"/>
|
||||||
|
<path id="foot_left_glare" fill="url(#fill_foot_left_glare)" filter="url(#blur_foot_left_glare)" clip-path="url(#clip_foot_left)"
|
||||||
|
d="m 51.2,188.21 c 2.25,4.06 3.62,8.72 5.85,12.82 2.05,3.77 4.38,7.65 6.46,11.12 0.93,1.55 3.09,3.93 5.27,7.62 1.98,3.34 3.98,8.01 5.1,9.58 -0.64,-1.84 -1.96,-6.77 -3.54,-10.28 -1.47,-3.28 -3.19,-5.15 -4.24,-6.92 -2.08,-3.47 -4.33,-6.6 -6.47,-9.91 -2.95,-4.57 -5.2,-9.68 -8.43,-14.03 z"/>
|
||||||
|
</g>
|
||||||
|
<g id="foot_right">
|
||||||
|
<path id="foot_right_shadow" opacity="0.2" fill="url(#fill_foot_right_shadow)" filter="url(#blur_foot_right_shadow)" clip-path="url(#clip_body)"
|
||||||
|
d="m 198.7,215.61 c -0.4,1.33 -1.02,2.62 -1.81,3.8 -1.75,2.59 -4.3,4.55 -6.84,6.35 -4.33,3.07 -8.85,5.89 -12.89,9.38 -2.7,2.34 -5.17,4.97 -7.45,7.73 -1.95,2.36 -3.79,4.84 -6.02,6.94 -2.25,2.12 -4.89,3.84 -7.74,4.77 -3.47,1.13 -7.13,1.08 -10.47,0.22 -2.34,-0.6 -4.63,-1.64 -6.08,-3.53 -1.45,-1.89 -1.92,-4.44 -2.09,-6.94 -0.3,-4.42 0.23,-8.93 0.71,-13.42 0.4,-3.73 0.77,-7.46 0.92,-11.18 0.27,-6.77 -0.18,-13.47 -1.09,-20.05 -0.16,-1.11 -0.32,-2.22 -0.23,-3.35 0.09,-1.14 0.47,-2.32 1.27,-3.2 0.74,-0.81 1.77,-1.29 2.79,-1.52 1.02,-0.24 2.06,-0.25 3.09,-0.28 2.43,-0.06 4.86,-0.21 7.25,0.01 1.51,0.13 2.99,0.41 4.49,0.55 2.51,0.24 5.12,0.12 7.64,-0.62 2.71,-0.8 5.29,-2.29 8.05,-2.7 1.13,-0.17 2.26,-0.15 3.36,0.01 1.12,0.15 2.24,0.46 3.1,1.15 0.66,0.52 1.14,1.23 1.51,1.99 0.56,1.14 0.9,2.39 1.1,3.68 0.17,1.14 0.24,2.31 0.53,3.41 0.48,1.81 1.58,3.35 2.89,4.6 1.32,1.25 2.85,2.24 4.39,3.22 1.53,0.97 3.07,1.93 4.7,2.73 0.77,0.38 1.56,0.72 2.29,1.15 0.74,0.44 1.42,0.97 1.91,1.67 0.66,0.95 0.92,2.2 0.72,3.43 z"/>
|
||||||
|
<path id="foot_right_base" fill="url(#fill_foot_right_base)"
|
||||||
|
d="m 213.47,222.92 c -2.26,2.68 -5.4,4.45 -8.53,6.05 -5.33,2.71 -10.86,5.1 -15.87,8.37 -3.36,2.19 -6.46,4.76 -9.36,7.53 -2.48,2.37 -4.83,4.9 -7.61,6.91 -2.81,2.03 -6.05,3.5 -9.48,4.01 -0.95,0.14 -1.9,0.21 -2.86,0.21 -3.24,0 -6.48,-0.78 -9.46,-2.08 -2.7,-1.17 -5.3,-2.86 -6.86,-5.36 -1.56,-2.52 -1.92,-5.59 -1.92,-8.56 -0.01,-5.23 0.96,-10.41 1.87,-15.57 0.76,-4.29 1.48,-8.58 1.95,-12.91 0.85,-7.86 0.84,-15.81 0.28,-23.71 -0.1,-1.32 -0.21,-2.65 -0.01,-3.96 0.2,-1.31 0.74,-2.62 1.74,-3.48 0.93,-0.8 2.17,-1.16 3.4,-1.22 1.22,-0.07 2.44,0.12 3.65,0.3 2.85,0.42 5.73,0.74 8.52,1.48 1.76,0.46 3.48,1.08 5.23,1.56 2.94,0.79 6.01,1.17 9.02,0.82 3.25,-0.38 6.41,-1.6 9.68,-1.52 1.34,0.03 2.67,0.28 3.95,0.69 1.3,0.41 2.59,1 3.55,1.98 0.73,0.74 1.24,1.67 1.62,2.64 0.57,1.44 0.88,2.98 1.01,4.52 0.11,1.37 0.09,2.76 0.35,4.11 0.43,2.21 1.6,4.24 3.04,5.97 1.45,1.74 3.18,3.21 4.91,4.66 1.73,1.45 3.46,2.89 5.32,4.16 0.87,0.6 1.77,1.16 2.6,1.81 0.83,0.66 1.59,1.42 2.11,2.34 0.45,0.81 0.69,1.72 0.69,2.65 0,0.52 -0.07,1.04 -0.23,1.56 -0.45,1.43 -1.28,2.82 -2.3,4.04 z"/>
|
||||||
|
<path id="foot_right_layer_1" fill="#cd8907" filter="url(#blur_foot_right_layer_1)" clip-path="url(#clip_foot_right)"
|
||||||
|
d="m 213.21,216.12 c -0.53,1.33 -1.28,2.58 -2.22,3.67 -2.07,2.42 -4.93,4.01 -7.78,5.44 -4.88,2.44 -9.92,4.58 -14.5,7.52 -3.06,1.97 -5.9,4.28 -8.55,6.78 -2.26,2.13 -4.41,4.41 -6.95,6.21 -2.57,1.83 -5.53,3.14 -8.65,3.6 -3.8,0.56 -7.72,-0.16 -11.25,-1.67 -2.46,-1.06 -4.84,-2.56 -6.27,-4.83 -1.42,-2.26 -1.75,-5.02 -1.75,-7.69 -0.02,-4.71 0.87,-9.37 1.71,-14 0.7,-3.85 1.36,-7.71 1.78,-11.6 0.76,-7.08 0.73,-14.22 0.25,-21.32 -0.08,-1.19 -0.17,-2.39 0.01,-3.57 0.18,-1.18 0.67,-2.35 1.57,-3.13 0.85,-0.73 1.99,-1.05 3.11,-1.1 1.11,-0.06 2.22,0.12 3.33,0.28 2.61,0.38 5.23,0.67 7.78,1.33 1.61,0.42 3.18,0.98 4.78,1.4 2.68,0.72 5.49,1.06 8.24,0.74 2.97,-0.34 5.85,-1.44 8.83,-1.37 1.23,0.03 2.44,0.26 3.61,0.62 1.19,0.37 2.37,0.9 3.25,1.78 0.66,0.67 1.11,1.51 1.48,2.38 0.53,1.29 0.89,2.67 0.91,4.07 0.03,1.46 -0.28,2.92 -0.09,4.37 0.16,1.17 0.66,2.28 1.3,3.28 0.63,1 1.4,1.91 2.17,2.81 1.48,1.75 2.96,3.53 4.82,4.87 2.11,1.53 4.62,2.43 6.8,3.85 0.65,0.43 1.28,0.91 1.74,1.54 0.78,1.06 0.98,2.5 0.54,3.74 z"/>
|
||||||
|
<path id="foot_right_layer_2" fill="#f5c021" filter="url(#blur_foot_right_layer_2)" clip-path="url(#clip_foot_right)"
|
||||||
|
d="m 212.91,214.61 c -0.6,1.35 -1.37,2.6 -2.28,3.71 -2.12,2.58 -4.99,4.35 -8,5.49 -4.97,1.88 -10.39,2.13 -15.26,4.27 -2.97,1.3 -5.65,3.26 -8.36,5.12 -2.18,1.49 -4.42,2.94 -6.82,3.98 -2.72,1.19 -5.6,1.85 -8.5,2.32 -1.84,0.29 -3.71,0.51 -5.57,0.41 -1.86,-0.1 -3.72,-0.54 -5.37,-1.49 -1.24,-0.72 -2.36,-1.75 -3.03,-3.1 -0.73,-1.49 -0.86,-3.24 -0.85,-4.94 0.05,-4.5 1.02,-8.96 0.99,-13.47 -0.03,-3.93 -0.81,-7.8 -1.03,-11.72 -0.43,-7.54 1.19,-15.2 -0.24,-22.59 -0.22,-1.19 -0.53,-2.37 -0.52,-3.58 0.01,-0.6 0.1,-1.21 0.31,-1.77 0.22,-0.55 0.56,-1.06 1.01,-1.42 0.39,-0.29 0.84,-0.47 1.31,-0.56 0.46,-0.08 0.94,-0.06 1.41,0.01 0.93,0.15 1.82,0.51 2.73,0.78 2.6,0.78 5.35,0.76 8,1.35 1.66,0.36 3.26,0.97 4.91,1.41 2.75,0.76 5.63,1.08 8.46,0.75 3.04,-0.36 6.01,-1.46 9.07,-1.38 1.26,0.03 2.5,0.26 3.71,0.62 1.21,0.36 2.42,0.87 3.34,1.8 0.65,0.67 1.13,1.52 1.51,2.4 0.57,1.29 0.96,2.69 0.95,4.11 -0.01,0.74 -0.12,1.47 -0.19,2.21 -0.06,0.74 -0.08,1.49 0.09,2.2 0.18,0.72 0.55,1.37 0.97,1.96 0.42,0.59 0.9,1.12 1.34,1.7 1.22,1.61 2.1,3.49 3.05,5.3 0.95,1.81 2.02,3.6 3.53,4.91 2.05,1.77 4.7,2.48 6.99,3.89 0.67,0.41 1.31,0.89 1.78,1.55 0.38,0.52 0.63,1.15 0.73,1.81 0.09,0.65 0.03,1.34 -0.17,1.96 z"/>
|
||||||
|
<path id="foot_right_glare" fill="url(#fill_foot_right_glare)" filter="url(#blur_foot_right_glare)" clip-path="url(#clip_foot_right)"
|
||||||
|
d="m 148.08,181.58 c 2.82,-0.76 5.22,1.38 7.27,2.99 1.32,1.13 3.24,0.85 4.86,0.9 2.69,-0.09 5.36,0.45 8.05,0.12 5.3,-0.45 10.49,-1.75 15.81,-1.97 2.54,-0.16 5.4,-0.31 7.59,1.17 0.89,0.62 2.2,3.23 3.07,2.25 -0.36,-2.74 -2.39,-5.39 -5.11,-6.12 -2.14,-0.34 -4.3,0.25 -6.46,0.06 -6.39,-0.15 -12.75,-1.34 -19.16,-1 -4.46,0.04 -8.91,-0.17 -13.37,-0.34 -1.75,-0.36 -2.37,1.19 -3.32,1.79 0.25,0.19 0.34,0.25 0.77,0.15 z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="wing_tip_right">
|
||||||
|
<g id="wing_tip_right_shadow">
|
||||||
|
<path id="wing_tip_right_shadow_lower" opacity="0.35" fill="url(#fill_wing_tip_right_shadow_lower)" filter="url(#blur_wing_tip_right_shadow_lower)" clip-path="url(#clip_foot_right)"
|
||||||
|
d="m 185.49,187.61 c -0.48,-0.95 -1.36,-1.66 -2.35,-2.07 -0.98,-0.41 -2.06,-0.55 -3.13,-0.54 -2.13,0.02 -4.25,0.57 -6.38,0.39 -1.79,-0.16 -3.49,-0.83 -5.24,-1.26 -1.81,-0.44 -3.73,-0.61 -5.52,-0.12 -1.92,0.52 -3.61,1.81 -4.67,3.49 -0.94,1.48 -1.38,3.23 -1.52,4.98 -0.14,1.75 0.01,3.5 0.19,5.25 0.12,1.26 0.27,2.52 0.57,3.75 0.31,1.23 0.78,2.43 1.52,3.46 1.07,1.48 2.66,2.54 4.37,3.17 2.8,1.03 5.98,0.98 8.73,-0.15 4.88,-2.12 9.01,-5.92 11.52,-10.6 0.91,-1.68 1.61,-3.47 2.06,-5.31 0.18,-0.74 0.32,-1.49 0.32,-2.25 0.01,-0.75 -0.12,-1.52 -0.47,-2.19 z"/>
|
||||||
|
<path id="wing_tip_right_shadow_upper" opacity="0.35" fill="url(#fill_wing_tip_right_shadow_upper)" filter="url(#blur_wing_tip_right_shadow_upper)" clip-path="url(#clip_foot_right)"
|
||||||
|
d="m 185.49,184.89 c -0.48,-0.69 -1.36,-1.2 -2.35,-1.5 -0.98,-0.3 -2.06,-0.39 -3.13,-0.39 -2.13,0.02 -4.25,0.42 -6.38,0.28 -1.79,-0.11 -3.49,-0.6 -5.24,-0.9 -1.81,-0.32 -3.73,-0.45 -5.52,-0.09 -1.92,0.37 -3.61,1.3 -4.67,2.52 -0.94,1.07 -1.38,2.34 -1.52,3.6 -0.14,1.26 0.01,2.53 0.19,3.79 0.12,0.91 0.27,1.83 0.57,2.72 0.31,0.89 0.78,1.76 1.52,2.5 1.07,1.07 2.66,1.83 4.37,2.29 2.8,0.75 5.98,0.71 8.73,-0.11 4.88,-1.53 9.01,-4.28 11.52,-7.66 0.91,-1.22 1.61,-2.51 2.06,-3.84 0.18,-0.54 0.32,-1.08 0.32,-1.62 0.01,-0.55 -0.12,-1.11 -0.47,-1.59 z"/>
|
||||||
|
</g>
|
||||||
|
<path id="wing_tip_right_base" fill="#020204"
|
||||||
|
d="m 189.55,178.72 c -0.35,-0.95 -0.97,-1.79 -1.72,-2.47 -0.75,-0.68 -1.64,-1.2 -2.57,-1.6 -1.86,-0.79 -3.89,-1.09 -5.89,-1.46 -1.87,-0.35 -3.74,-0.78 -5.62,-1.1 -1.96,-0.33 -3.98,-0.55 -5.92,-0.11 -1.69,0.38 -3.26,1.26 -4.54,2.43 -1.28,1.17 -2.28,2.63 -3,4.21 -1.27,2.79 -1.67,5.92 -1.43,8.97 0.18,2.27 0.76,4.61 2.25,6.32 1.21,1.39 2.92,2.26 4.68,2.78 3.04,0.9 6.35,0.85 9.36,-0.13 4.97,-1.67 9.37,-4.98 12.35,-9.29 0.98,-1.43 1.82,-2.98 2.2,-4.66 0.29,-1.28 0.3,-2.66 -0.15,-3.89 z"/>
|
||||||
|
<g id="wing_tip_right_glare">
|
||||||
|
<defs>
|
||||||
|
<path id="path_wing_tip_right_glare"
|
||||||
|
d="m 168.89,171.07 c -0.47,0.03 -0.93,0.08 -1.4,0.17 -2.99,0.53 -5.73,2.42 -7.27,5.03 -1.09,1.85 -1.58,4.03 -1.43,6.17 0.07,-1.5 0.46,-2.97 1.19,-4.28 1.23,-2.23 3.47,-3.91 5.98,-4.37 1.54,-0.28 3.13,-0.11 4.68,0.08 1.5,0.19 3,0.39 4.47,0.7 2.28,0.5 4.53,1.26 6.44,2.59 0.44,0.31 0.86,0.66 1.21,1.08 0.35,0.41 0.62,0.89 0.73,1.42 0.15,0.78 -0.07,1.6 -0.46,2.29 -0.39,0.7 -0.92,1.3 -1.48,1.86 -0.46,0.46 -0.94,0.89 -1.43,1.32 2.21,-0.43 4.44,-1.03 6.28,-2.31 0.77,-0.55 1.48,-1.2 1.94,-2.02 0.46,-0.83 0.65,-1.83 0.43,-2.75 -0.16,-0.62 -0.5,-1.19 -0.92,-1.67 -0.42,-0.48 -0.93,-0.87 -1.45,-1.24 -2.31,-1.62 -5.01,-2.65 -7.81,-2.99 -1.8,-0.33 -3.61,-0.61 -5.42,-0.83 -1.41,-0.18 -2.86,-0.33 -4.28,-0.25 z"/>
|
||||||
|
</defs>
|
||||||
|
<use id="wing_tip_right_glare_1" href="#path_wing_tip_right_glare" xlink:href="#path_wing_tip_right_glare"
|
||||||
|
fill="url(#fill_wing_tip_right_glare_1)" filter="url(#blur_wing_tip_right_glare)" clip-path="url(#clip_wing_tip_right)"/>
|
||||||
|
<use id="wing_tip_right_glare_2" href="#path_wing_tip_right_glare" xlink:href="#path_wing_tip_right_glare"
|
||||||
|
fill="url(#fill_wing_tip_right_glare_2)" filter="url(#blur_wing_tip_right_glare)" clip-path="url(#clip_wing_tip_right)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="face">
|
||||||
|
<g id="eyes">
|
||||||
|
<g id="eye_left">
|
||||||
|
<path id="eyeball_left" fill="url(#fill_eyeball_left)"
|
||||||
|
d="m 84.45,38.28 c -1.53,0.08 -3,0.79 -4.12,1.84 -1.13,1.05 -1.92,2.43 -2.41,3.88 -0.97,2.92 -0.75,6.08 -0.53,9.15 0.2,2.77 0.41,5.6 1.45,8.18 0.52,1.3 1.25,2.51 2.22,3.51 0.97,0.99 2.2,1.76 3.55,2.09 1.26,0.32 2.62,0.26 3.86,-0.13 1.25,-0.4 2.38,-1.11 3.32,-2.02 1.36,-1.33 2.27,-3.07 2.8,-4.9 0.53,-1.83 0.68,-3.75 0.65,-5.66 -0.04,-2.38 -0.35,-4.77 -1.09,-7.03 -0.75,-2.26 -1.94,-4.4 -3.6,-6.11 -0.8,-0.83 -1.72,-1.55 -2.75,-2.06 -1.04,-0.51 -2.2,-0.8 -3.35,-0.74 z"/>
|
||||||
|
<g id="pupil_left">
|
||||||
|
<path id="pupil_left_base" fill="#020204"
|
||||||
|
d="m 80.75,50.99 c -0.32,1.94 -0.33,3.97 0.33,5.81 0.44,1.22 1.17,2.33 2.05,3.28 0.57,0.62 1.23,1.18 1.99,1.55 0.77,0.37 1.65,0.52 2.48,0.32 0.76,-0.19 1.42,-0.68 1.91,-1.29 0.49,-0.61 0.82,-1.34 1.05,-2.09 0.69,-2.21 0.58,-4.62 -0.11,-6.83 -0.49,-1.61 -1.32,-3.16 -2.6,-4.24 -0.62,-0.52 -1.34,-0.93 -2.12,-1.11 -0.78,-0.19 -1.63,-0.14 -2.36,0.19 -0.81,0.37 -1.44,1.07 -1.85,1.86 -0.41,0.79 -0.62,1.67 -0.77,2.55 z"/>
|
||||||
|
<path id="pupil_left_glare" fill="url(#fill_pupil_left_glare)" filter="url(#blur_pupil_left_glare)" clip-path="url(#clip_pupil_left)"
|
||||||
|
d="m 84.84,49.59 c 0.21,0.55 0.91,0.75 1.3,1.19 0.37,0.42 0.76,0.87 0.97,1.4 0.39,1.01 -0.39,2.51 0.43,3.23 0.25,0.22 0.77,0.23 1.02,0 0.99,-0.9 0.77,-2.71 0.38,-3.99 -0.36,-1.15 -1.23,-2.25 -2.31,-2.8 -0.5,-0.26 -1.25,-0.47 -1.68,-0.11 -0.27,0.24 -0.24,0.74 -0.11,1.08 z"/>
|
||||||
|
</g>
|
||||||
|
<path id="eyelid_left" fill="url(#fill_eyelid_left)" clip-path="url(#clip_eye_left)"
|
||||||
|
d="m 81.14,44.46 c 2.32,-1.38 5.13,-1.7 7.82,-1.45 2.68,0.26 5.27,1.04 7.87,1.75 1.91,0.52 3.84,1 5.63,1.84 1.78,0.84 3.44,2.08 4.43,3.8 0.16,0.27 0.29,0.56 0.46,0.83 0.17,0.27 0.37,0.52 0.62,0.71 0.25,0.19 0.57,0.32 0.88,0.3 0.16,-0.01 0.32,-0.05 0.45,-0.13 0.14,-0.08 0.26,-0.2 0.33,-0.34 0.08,-0.16 0.11,-0.35 0.1,-0.53 -0.01,-0.18 -0.05,-0.36 -0.1,-0.54 -0.65,-2.37 -2.19,-4.38 -3.35,-6.55 -0.7,-1.3 -1.28,-2.66 -1.98,-3.96 -2.43,-4.45 -6.42,-7.94 -10.95,-10.21 -4.53,-2.27 -9.59,-3.36 -14.65,-3.65 -5.86,-0.35 -11.73,0.35 -17.51,1.37 -2.51,0.44 -5.06,0.96 -7.27,2.21 -1.11,0.62 -2.13,1.42 -2.92,2.42 -0.8,0.99 -1.36,2.18 -1.55,3.44 -0.17,1.22 0.01,2.47 0.44,3.62 0.42,1.15 1.08,2.2 1.86,3.15 1.54,1.91 3.53,3.39 5.36,5.03 1.83,1.63 3.52,3.44 5.57,4.79 1.02,0.68 2.13,1.24 3.31,1.57 1.18,0.33 2.44,0.42 3.64,0.17 1.24,-0.25 2.4,-0.86 3.41,-1.64 1.01,-0.77 1.88,-1.7 2.71,-2.66 1.66,-1.93 3.21,-4.04 5.39,-5.34 z"/>
|
||||||
|
<path id="eyebrow_left" fill="url(#fill_eyebrow_left)" filter="url(#blur_eyebrow_left)"
|
||||||
|
d="m 90.77,36.57 c 2.16,2.02 3.76,4.52 4.85,7.16 -0.48,-2.91 -1.23,-5.26 -3.13,-7.16 -1.16,-1.09 -2.49,-2.05 -3.98,-2.72 -1.32,-0.59 -2.77,-0.96 -3.61,-0.97 -0.83,-0.02 -1.03,0 -1.2,0.01 -0.18,0.01 -0.31,0.01 0.23,0.08 0.54,0.06 1.75,0.39 3.05,0.97 1.3,0.58 2.62,1.54 3.79,2.63 z"/>
|
||||||
|
</g>
|
||||||
|
<g id="eye_right">
|
||||||
|
<path id="eyeball_right" fill="url(#fill_eyeball_right)"
|
||||||
|
d="m 111.61,38.28 c -2.39,1.65 -4.4,3.94 -5.38,6.68 -1.24,3.45 -0.77,7.31 0.43,10.77 1.22,3.55 3.27,6.93 6.36,9.06 1.54,1.07 3.33,1.8 5.19,2.02 1.87,0.22 3.8,-0.09 5.47,-0.95 2.02,-1.06 3.57,-2.91 4.53,-4.98 0.96,-2.08 1.37,-4.37 1.5,-6.66 0.16,-2.9 -0.12,-5.86 -1.08,-8.61 -1.04,-2.99 -2.92,-5.75 -5.58,-7.47 -1.32,-0.86 -2.83,-1.45 -4.4,-1.67 -1.57,-0.22 -3.19,-0.05 -4.67,0.52 -0.84,0.33 -1.62,0.78 -2.37,1.29 z"/>
|
||||||
|
<g id="pupil_right">
|
||||||
|
<path id="pupil_right_base" fill="#020204"
|
||||||
|
d="m 117.14,45.52 c -0.9,0.06 -1.78,0.37 -2.55,0.85 -0.76,0.48 -1.41,1.13 -1.92,1.88 -1.03,1.49 -1.48,3.31 -1.55,5.12 -0.05,1.35 0.1,2.72 0.55,4 0.45,1.28 1.2,2.47 2.25,3.33 1.07,0.89 2.42,1.42 3.81,1.49 1.39,0.06 2.79,-0.34 3.93,-1.13 0.91,-0.63 1.64,-1.5 2.16,-2.48 0.52,-0.97 0.84,-2.05 0.98,-3.15 0.25,-1.93 -0.03,-3.95 -0.93,-5.69 -0.89,-1.74 -2.41,-3.17 -4.24,-3.84 -0.8,-0.29 -1.65,-0.44 -2.49,-0.38 z"/>
|
||||||
|
<path id="pupil_right_glare" fill="url(#fill_pupil_right_glare)" filter="url(#blur_pupil_right_glare)" clip-path="url(#clip_pupil_right)"
|
||||||
|
d="m 122.71,53.36 c 1,-1 -0.71,-3.65 -2.05,-4.74 -0.97,-0.78 -3.78,-1.61 -3.66,-0.75 0.12,0.85 1.39,1.95 2.23,2.79 1.05,1.03 3,3.18 3.48,2.7 z"/>
|
||||||
|
</g>
|
||||||
|
<path id="eyelid_right" fill="url(#fill_eyelid_right)" clip-path="url(#clip_eye_right)"
|
||||||
|
d="m 102.56,47.01 c 2.06,-1.71 4.45,-3.01 7,-3.8 5.25,-1.62 11.2,-0.98 15.84,1.97 1.6,1.01 3.03,2.27 4.52,3.45 1.48,1.17 3.06,2.27 4.85,2.9 0.97,0.34 2,0.54 3.02,0.43 0.92,-0.09 1.81,-0.44 2.57,-0.96 0.76,-0.53 1.4,-1.23 1.88,-2.02 0.96,-1.58 1.27,-3.5 1.1,-5.34 -0.33,-3.69 -2.41,-6.94 -4.15,-10.21 -0.55,-1.02 -1.07,-2.06 -1.73,-3.01 -2.01,-2.93 -5.23,-4.86 -8.6,-5.99 -3.37,-1.13 -6.93,-1.54 -10.46,-1.98 -1.58,-0.2 -3.17,-0.41 -4.74,-0.22 -1.81,0.22 -3.51,0.95 -5.28,1.4 -0.84,0.22 -1.69,0.37 -2.52,0.61 -0.83,0.24 -1.65,0.57 -2.33,1.11 -0.98,0.79 -1.6,1.98 -1.87,3.21 -0.27,1.24 -0.21,2.52 -0.01,3.77 0.39,2.5 1.33,4.93 1.24,7.46 -0.06,1.73 -0.61,3.44 -0.54,5.17 0.02,0.51 0.12,1.55 0.21,2.05 z"/>
|
||||||
|
<path id="eyebrow_right" fill="url(#fill_eyebrow_right)" filter="url(#blur_eyebrow_right)"
|
||||||
|
d="m 119.93,31.18 c -0.41,0.52 -0.78,1.08 -1.07,1.7 1.85,0.4 3.61,1.16 5.19,2.21 3.06,2.03 5.38,4.99 7.01,8.29 0.38,-0.42 0.72,-0.87 1.02,-1.37 -1.64,-3.44 -4,-6.55 -7.16,-8.65 -1.52,-1 -3.21,-1.77 -4.99,-2.18 z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="beak">
|
||||||
|
<g id="beak_shadow">
|
||||||
|
<path id="beak_shadow_lower" fill="#000000" fill-opacity="0.258824" filter="url(#blur_beak_shadow_lower)" clip-path="url(#clip_body)"
|
||||||
|
d="m 81.12,89.33 c 1.47,4.26 4.42,7.89 7.92,10.72 1.16,0.95 2.39,1.82 3.76,2.43 1.36,0.62 2.87,0.97 4.36,0.84 1.46,-0.12 2.85,-0.7 4.13,-1.42 1.28,-0.72 2.46,-1.59 3.7,-2.37 2.12,-1.35 4.39,-2.44 6.6,-3.64 2.65,-1.45 5.23,-3.1 7.46,-5.14 1.03,-0.93 1.98,-1.95 3.11,-2.75 1.13,-0.81 2.49,-1.39 3.87,-1.29 1.04,0.07 2.01,0.51 3.03,0.73 0.51,0.11 1.03,0.16 1.55,0.08 0.51,-0.08 1.01,-0.29 1.37,-0.67 0.44,-0.46 0.64,-1.12 0.61,-1.76 -0.02,-0.63 -0.24,-1.25 -0.54,-1.81 -0.59,-1.13 -1.49,-2.1 -1.89,-3.31 -0.36,-1.08 -0.29,-2.24 -0.26,-3.37 0.03,-1.14 0.01,-2.32 -0.51,-3.33 -0.4,-0.76 -1.07,-1.37 -1.83,-1.77 -0.76,-0.41 -1.62,-0.62 -2.48,-0.7 -1.72,-0.16 -3.44,0.18 -5.17,0.27 -2.28,0.13 -4.58,-0.15 -6.87,-0.02 -2.85,0.18 -5.65,1 -8.51,1.01 -3.26,0.01 -6.52,-1.06 -9.74,-0.55 -1.39,0.22 -2.71,0.72 -4.03,1.16 -1.33,0.45 -2.7,0.84 -4.1,0.82 -1.59,-0.03 -3.13,-0.58 -4.72,-0.69 -0.79,-0.06 -1.6,0 -2.35,0.28 -0.74,0.28 -1.41,0.79 -1.78,1.5 -0.21,0.4 -0.31,0.86 -0.33,1.31 -0.02,0.46 0.04,0.91 0.15,1.36 0.22,0.88 0.63,1.71 0.96,2.55 1.2,3.07 1.46,6.42 2.53,9.53 z"/>
|
||||||
|
<path id="beak_shadow_upper" opacity="0.3" fill="#000000" filter="url(#blur_beak_shadow_upper)" clip-path="url(#clip_body)"
|
||||||
|
d="m 77.03,77.2 c 2.85,1.76 5.41,3.93 7.56,6.39 1.99,2.29 3.68,4.89 6.29,6.58 1.83,1.2 4.04,1.87 6.28,2.08 2.63,0.24 5.29,-0.15 7.83,-0.84 2.35,-0.63 4.62,-1.53 6.7,-2.71 3.97,-2.25 7.28,-5.55 11.65,-7.03 0.95,-0.33 1.94,-0.56 2.86,-0.96 0.92,-0.39 1.79,-0.99 2.23,-1.83 0.42,-0.82 0.4,-1.75 0.54,-2.64 0.15,-0.96 0.48,-1.88 0.66,-2.83 0.18,-0.95 0.2,-1.96 -0.24,-2.83 -0.37,-0.72 -1.04,-1.29 -1.81,-1.66 -0.77,-0.36 -1.64,-0.52 -2.51,-0.56 -1.72,-0.08 -3.43,0.33 -5.16,0.47 -2.28,0.19 -4.58,-0.08 -6.87,-0.01 -2.85,0.08 -5.66,0.67 -8.51,0.8 -3.25,0.14 -6.49,-0.34 -9.74,-0.44 -1.41,-0.05 -2.83,-0.03 -4.21,0.2 -1.39,0.22 -2.75,0.65 -3.92,1.37 -1.14,0.69 -2.07,1.64 -3.11,2.45 -0.52,0.41 -1.08,0.78 -1.68,1.07 -0.61,0.28 -1.28,0.48 -1.96,0.51 -0.35,0.01 -0.71,-0.01 -1.05,0.04 -0.59,0.08 -1.13,0.39 -1.47,0.83 -0.34,0.45 -0.47,1.02 -0.36,1.55 z"/>
|
||||||
|
</g>
|
||||||
|
<path id="beak_base" fill="url(#fill_beak_base)"
|
||||||
|
d="m 91.66,58.53 c 1.53,-1.71 2.57,-3.8 4.03,-5.56 0.73,-0.88 1.58,-1.69 2.57,-2.26 0.99,-0.57 2.15,-0.89 3.29,-0.79 1.27,0.11 2.46,0.74 3.39,1.61 0.93,0.87 1.62,1.97 2.17,3.12 0.53,1.11 0.95,2.28 1.71,3.24 0.81,1.02 1.94,1.71 2.97,2.52 0.51,0.4 1.01,0.83 1.41,1.34 0.41,0.51 0.72,1.1 0.86,1.74 0.13,0.65 0.06,1.33 -0.16,1.95 -0.23,0.62 -0.61,1.18 -1.09,1.64 -0.95,0.92 -2.25,1.42 -3.56,1.6 -2.62,0.37 -5.27,-0.41 -7.92,-0.34 -2.67,0.08 -5.29,1.02 -7.97,0.93 -1.33,-0.05 -2.69,-0.38 -3.79,-1.14 -0.55,-0.39 -1.03,-0.88 -1.38,-1.45 -0.34,-0.57 -0.55,-1.23 -0.58,-1.9 -0.02,-0.64 0.13,-1.28 0.39,-1.86 0.25,-0.59 0.61,-1.12 1.01,-1.62 0.81,-0.99 1.8,-1.81 2.65,-2.77 z"/>
|
||||||
|
<g id="mandible_lower">
|
||||||
|
<path id="mandible_lower_base" fill="url(#fill_mandible_lower_base)"
|
||||||
|
d="m 77.14,75.05 c 0.06,0.26 0.15,0.5 0.28,0.73 0.23,0.38 0.57,0.69 0.93,0.95 0.36,0.27 0.75,0.49 1.13,0.72 2.01,1.27 3.65,3.04 5.11,4.92 1.95,2.52 3.68,5.31 6.29,7.14 1.84,1.3 4.04,2.03 6.28,2.26 2.63,0.26 5.29,-0.16 7.83,-0.91 2.35,-0.69 4.62,-1.66 6.7,-2.95 3.97,-2.44 7.28,-6.02 11.65,-7.63 0.95,-0.35 1.94,-0.6 2.86,-1.03 0.92,-0.44 1.79,-1.08 2.23,-2 0.42,-0.88 0.4,-1.9 0.54,-2.87 0.15,-1.03 0.48,-2.03 0.66,-3.06 0.18,-1.03 0.2,-2.13 -0.24,-3.08 -0.37,-0.78 -1.04,-1.4 -1.81,-1.79 -0.77,-0.4 -1.64,-0.58 -2.51,-0.62 -1.72,-0.08 -3.43,0.36 -5.16,0.52 -2.28,0.21 -4.58,-0.09 -6.87,-0.02 -2.85,0.09 -5.66,0.73 -8.51,0.87 -3.25,0.15 -6.49,-0.35 -9.74,-0.48 -1.41,-0.06 -2.83,-0.04 -4.22,0.2 -1.39,0.23 -2.75,0.71 -3.91,1.51 -1.13,0.78 -2.03,1.84 -3.07,2.74 -0.52,0.45 -1.08,0.86 -1.7,1.16 -0.61,0.3 -1.29,0.49 -1.98,0.47 -0.35,-0.01 -0.72,-0.06 -1.05,0.04 -0.21,0.07 -0.4,0.2 -0.56,0.35 -0.16,0.16 -0.29,0.34 -0.41,0.52 -0.29,0.42 -0.54,0.87 -0.75,1.34 z"/>
|
||||||
|
<path id="mandible_lower_glare" fill="#d9b30d" filter="url(#blur_mandible_lower_glare)" clip-path="url(#clip_mandible_lower)"
|
||||||
|
d="m 89.9,78.56 c -0.33,1.37 -0.13,2.87 0.56,4.11 0.68,1.24 1.84,2.2 3.19,2.65 1.7,0.57 3.62,0.29 5.21,-0.54 0.93,-0.48 1.77,-1.16 2.3,-2.06 0.27,-0.44 0.46,-0.94 0.53,-1.46 0.06,-0.51 0.02,-1.05 -0.16,-1.54 -0.2,-0.53 -0.56,-1 -0.99,-1.37 -0.44,-0.37 -0.95,-0.64 -1.5,-0.82 -1.08,-0.36 -2.77,-0.66 -3.91,-0.68 -2.02,-0.04 -4.9,0.34 -5.23,1.71 z"/>
|
||||||
|
</g>
|
||||||
|
<g id="mandible_upper">
|
||||||
|
<path id="mandible_upper_shadow" fill="#604405" filter="url(#blur_mandible_upper_shadow)" clip-path="url(#clip_mandible_lower)"
|
||||||
|
d="m 84.31,67.86 c -1.16,0.68 -2.27,1.43 -3.36,2.2 -0.57,0.41 -1.15,0.84 -1.45,1.47 -0.21,0.44 -0.26,0.94 -0.27,1.43 0,0.5 0.03,0.99 -0.04,1.48 -0.04,0.33 -0.13,0.66 -0.14,0.99 -0.01,0.17 0,0.34 0.04,0.5 0.05,0.16 0.13,0.32 0.24,0.44 0.15,0.16 0.35,0.26 0.56,0.32 0.21,0.06 0.42,0.09 0.64,0.14 1.01,0.24 1.89,0.86 2.66,1.56 0.77,0.69 1.47,1.48 2.28,2.13 2.18,1.78 5.07,2.52 7.89,2.56 2.82,0.05 5.61,-0.54 8.36,-1.16 2.16,-0.49 4.32,-0.99 6.39,-1.76 3.2,-1.18 6.16,-2.96 8.72,-5.19 1.17,-1.01 2.26,-2.12 3.57,-2.94 1.15,-0.73 2.44,-1.21 3.62,-1.9 0.11,-0.06 0.21,-0.13 0.3,-0.2 0.1,-0.08 0.18,-0.18 0.24,-0.28 0.09,-0.19 0.09,-0.42 0.03,-0.62 -0.06,-0.2 -0.18,-0.38 -0.31,-0.55 -0.15,-0.18 -0.31,-0.34 -0.49,-0.5 -1.23,-1.05 -2.89,-1.43 -4.51,-1.56 -1.61,-0.12 -3.24,-0.03 -4.83,-0.3 -1.5,-0.25 -2.92,-0.81 -4.37,-1.27 -1.52,-0.49 -3.07,-0.87 -4.64,-1.13 -3.71,-0.61 -7.52,-0.49 -11.19,0.27 -3.49,0.73 -6.87,2.05 -9.94,3.87 z"/>
|
||||||
|
<path id="mandible_upper_base" fill="url(#fill_mandible_upper_base)"
|
||||||
|
d="m 83.94,63.95 c -1.66,1.12 -3.16,2.49 -4.43,4.04 -0.72,0.89 -1.38,1.86 -1.74,2.94 -0.29,0.86 -0.39,1.76 -0.57,2.65 -0.07,0.33 -0.15,0.66 -0.14,1 0,0.16 0.02,0.33 0.07,0.5 0.05,0.16 0.14,0.31 0.25,0.43 0.2,0.2 0.47,0.31 0.74,0.37 0.28,0.05 0.56,0.06 0.84,0.09 1.25,0.15 2.4,0.75 3.44,1.47 1.04,0.71 2,1.55 3.07,2.22 2.35,1.49 5.16,2.15 7.95,2.26 2.78,0.11 5.56,-0.31 8.3,-0.86 2.17,-0.43 4.33,-0.95 6.39,-1.76 3.16,-1.25 6.01,-3.16 8.72,-5.19 1.24,-0.92 2.46,-1.87 3.57,-2.94 0.37,-0.37 0.74,-0.74 1.14,-1.08 0.4,-0.33 0.85,-0.62 1.35,-0.78 0.76,-0.24 1.58,-0.17 2.37,-0.04 0.59,0.1 1.18,0.23 1.78,0.21 0.3,-0.02 0.6,-0.07 0.88,-0.18 0.28,-0.11 0.54,-0.28 0.73,-0.52 0.25,-0.3 0.38,-0.7 0.38,-1.09 0,-0.4 -0.12,-0.79 -0.32,-1.13 -0.4,-0.68 -1.09,-1.14 -1.81,-1.46 -0.99,-0.44 -2.06,-0.65 -3.11,-0.91 -3.23,-0.78 -6.37,-1.93 -9.34,-3.41 -1.48,-0.73 -2.92,-1.54 -4.37,-2.32 -1.5,-0.8 -3.02,-1.57 -4.64,-2.07 -3.64,-1.1 -7.6,-0.74 -11.19,0.51 -3.98,1.38 -7.58,3.84 -10.31,7.05 z"/>
|
||||||
|
<path id="mandible_upper_glare" fill="#f6da4a" filter="url(#blur_mandible_upper_glare)" clip-path="url(#clip_mandible_upper)"
|
||||||
|
d="m 109.45,64.75 c -0.2,-0.24 -0.48,-0.42 -0.78,-0.51 -0.3,-0.09 -0.62,-0.09 -0.93,-0.04 -0.62,0.11 -1.18,0.44 -1.7,0.8 -1.47,1.01 -2.77,2.26 -3.91,3.64 -1.5,1.83 -2.74,3.94 -3.16,6.27 -0.07,0.39 -0.11,0.8 -0.07,1.19 0.05,0.4 0.2,0.79 0.49,1.07 0.24,0.25 0.58,0.4 0.92,0.45 0.35,0.05 0.71,0 1.04,-0.11 0.66,-0.22 1.21,-0.69 1.74,-1.15 2.87,-2.58 5.47,-5.66 6.51,-9.38 0.1,-0.37 0.19,-0.75 0.19,-1.14 0,-0.39 -0.1,-0.78 -0.34,-1.09 z"/>
|
||||||
|
<path id="naris_left" opacity="0.8" fill="url(#fill_naris_left)" filter="url(#blur_naris_left)"
|
||||||
|
d="m 92.72,59.06 c -0.77,-0.25 -2.03,1.1 -1.62,1.79 0.11,0.19 0.46,0.43 0.7,0.3 0.35,-0.19 0.64,-0.89 1.02,-1.16 0.25,-0.18 0.2,-0.84 -0.1,-0.93 z"/>
|
||||||
|
<path id="naris_right" opacity="0.8" fill="url(#fill_naris_right)" filter="url(#blur_naris_right)"
|
||||||
|
d="m 102.56,59.42 c 0.2,0.64 1.23,0.53 1.83,0.84 0.52,0.27 0.94,0.86 1.53,0.88 0.56,0.01 1.44,-0.2 1.51,-0.76 0.09,-0.73 -0.98,-1.2 -1.67,-1.47 -0.89,-0.34 -2.03,-0.52 -2.86,-0.06 -0.19,0.11 -0.4,0.36 -0.34,0.57 z"/>
|
||||||
|
</g>
|
||||||
|
<path id="beak_corner" fill="url(#fill_beak_corner)" filter="url(#blur_beak_corner)" clip-path="url(#clip_beak)"
|
||||||
|
d="m 129.27,69.15 a 2.42,3.1 16.94 0 1 -2.81,3.04 2.42,3.1 16.94 0 1 -2.12,-3.04 2.42,3.1 16.94 0 1 2.81,-3.05 2.42,3.1 16.94 0 1 2.12,3.05 z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="814" height="1000">
|
||||||
|
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 660 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="165.144565" viewBox="0 0 20.325128 5.4768721" width="605.530071" xmlns="http://www.w3.org/2000/svg"><g transform="translate(129.603041 -242.100849)"><g stroke-width=".264583"><path d="m-128.3595 243.00043h-.15875v.37042h.15875c.10583 0 .18521-.0794.18521-.18521 0-.10583-.0794-.18521-.18521-.18521z" fill="#006693"/><path d="m-128.3595 242.2596c-.60854 0-1.11125.50271-1.11125 1.11125s.50271 1.11125 1.11125 1.11125 1.11125-.50271 1.11125-1.11125-.50271-1.11125-1.11125-1.11125zm.29104 1.69333-.26458-.37042h-.18521v.37042h-.21167v-1.19062h.37042c.21167 0 .39687.1852.39687.42333 0 .13229-.0794.26458-.1852.34396l.34395.44979h-.26458z" fill="#006693"/><path d="m-119.787 243.90001c-.0265.0529-.0529.10584-.15875.10584-.0529 0-.15875-.0265-.15875-.18521v-.68792h.55562v-.37041h-.55562v-.39688h-.37042v.39688h-.26458v.37041h.26458v.68792c0 .34396.29104.55562.55563.55562.26458 0 .44979-.1852.52916-.39687z"/><path d="m-124.47013 242.76231v.0794c-.10583-.0794-.23812-.13229-.37041-.13229-.42334 0-.74084.37042-.74084.84667 0 .44979.34396.84666.74084.84666.13229 0 .26458-.0529.37041-.10583v.0529h.37042v-1.5875zm-.37041 1.24354c-.21167 0-.37042-.21167-.37042-.44979 0-.26459.15875-.4498.37042-.4498.21166 0 .37041.21167.37041.4498 0 .26458-.15875.44979-.37041.44979z"/><path d="m-121.26867 242.76231v.92604c0 .18521-.13229.3175-.3175.3175s-.3175-.13229-.3175-.3175v-.92604h-.37041v.92604c0 .39687.3175.68791.68791.68791.10584 0 .21167-.0265.3175-.0794v.0529h.37042v-1.5875z"/><path d="m-117.06179 242.70939c-.10584 0-.21167.0265-.3175.0794v-.0264h-.37042v1.5875h.37042v-.92605c0-.1852.13229-.3175.3175-.3175s.3175.1323.3175.3175v.92605h.37041v-.92605c.0265-.39687-.29104-.71437-.68791-.71437z"/><path d="m-117.90846 243.55606c0-.4498-.34396-.84667-.74083-.84667-.42334 0-.74084.37042-.74084.84667 0 .5027.42334.84666.79375.84666.18521 0 .4498-.0529.66146-.34396l-.3175-.1852c-.29104.34395-.66146.10583-.71437-.18521h1.05833c0-.0529 0-.10584 0-.13229zm-.42333-.21167h-.635c.0794-.3175.55562-.39688.635 0z"/></g><path d="m26.8 2h-2l-2.2 2.2v-4.2h-1.4v8h1.4v-2.5l2.6 2.5h2l-3.2-3.2z" transform="matrix(.26458333 0 0 .26458333 -129.47075 242.23314)"/><path d="m-126.66617 244.34981v-.60855h.26459l.47625.60855h.47625l-.55563-.74084c.18521-.13229.29104-.3175.29104-.55562 0-.37042-.3175-.68792-.68791-.68792h-.635v1.98438zm0-1.61396h.23813c.15875 0 .3175.13229.3175.3175 0 .15875-.13229.3175-.3175.3175h-.23813z" stroke-width=".264583"/><g fill="#006693"><path d="m-125.29033 244.85251c.84666 0 1.42875.55563 1.27 1.29646-.15875.71438-.97896 1.29646-1.82563 1.29646s-1.42875-.58208-1.27-1.29646c.15875-.71437.97896-1.29646 1.82563-1.29646m-.47625 2.16959c.55562 0 1.08479-.39688 1.19062-.87313s-.26458-.87312-.82021-.87312c-.55562 0-1.08479.39687-1.19062.87312-.0794.47625.26458.87313.82021.87313" stroke-width=".264583"/><path d="m23.2 12.5 1 4.6 3-4.6h2.2l-5 7h-1.6l-1.8-7z" transform="matrix(.26458333 0 0 .26458333 -129.47075 242.23314)"/><g stroke-width=".264583"><path d="m-121.55971 246.65168c0 .13229.15875.37042.52917.37042.23812 0 .42333-.0794.55562-.23813h.55563c-.21167.34396-.68792.66146-1.21709.66146-.68791 0-1.08479-.44979-.9525-.97896.10584-.52916.68792-1.00541 1.37584-1.00541.71437 0 1.03187.5027.92604.97895-.0265.0794-.0529.15875-.0794.23813h-1.69333zm1.27-.34396c0-.29104-.21167-.42333-.50271-.42333-.21166 0-.55562.0794-.71437.42333z"/><path d="m-119.39013 245.54043h.47625l-.0264.15875c.13229-.13229.23812-.23812.52916-.23812h.0529l-.10583.42333c-.47625.0265-.55563.34396-.58209.44979l-.23812 1.05833h-.52917z"/><path d="m-118.09367 244.90543h.66146c.71438 0 1.00542.15875 1.13771.29104.23812.21167.37042.58209.26458.9525-.10583.44979-.47625.87313-.9525 1.08479-.29104.1323-.68791.15875-1.00541.15875h-.68792zm.0794 2.06375h.10583c.29104 0 .50271-.0265.74083-.13229.3175-.13229.52917-.42333.58209-.68792.0529-.23812-.0265-.47625-.18521-.60854-.18521-.18521-.47625-.21167-.82021-.21167h-.10583z"/><path d="m-115.71242 245.54043h.47625l-.0265.15875c.1323-.13229.23813-.23812.52917-.23812h.0529l-.10584.42333c-.47625.0265-.55562.34396-.58208.44979l-.23812 1.05833h-.52917z"/><path d="m-114.57471 245.54043h.52917l-.42334 1.87854h-.52916zm.15875-.635h.52917l-.0794.39688h-.52916z"/></g><path d="m60.6 19.5-1.8-7h2.1l1 4.6h.1l2.9-4.6h2.2l-4.9 7z" transform="matrix(.26458333 0 0 .26458333 -129.47075 242.23314)"/><path d="m-111.55846 246.65168c0 .13229.15875.37042.52917.37042.23812 0 .42333-.0794.55562-.23813h.58209c-.21167.34396-.68792.66146-1.21709.66146-.68791 0-1.08479-.44979-.9525-.97896.10584-.52916.68792-1.00541 1.37584-1.00541.71437 0 1.03187.5027.92604.97895-.0265.0794-.0529.15875-.0794.23813h-1.71979zm1.29646-.34396c0-.29104-.21167-.42333-.50271-.42333-.21167 0-.55562.0794-.71437.42333z" stroke-width=".264583"/><path d="m-109.41533 245.48751c-.0265.10584-.1323.18521-.23813.18521s-.18521-.0794-.15875-.18521c.0265-.10583.13229-.1852.23813-.1852.10583-.0265.1852.0794.15875.1852m-.34396 0c-.0265.0794.0265.15875.13229.15875.0794 0 .15875-.0794.18521-.15875.0Line truncated
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="573" viewBox="-2.5899701 .266 62.2909701 13.27921172" width="2500" xmlns="http://www.w3.org/2000/svg"><g stroke-width=".265"><path d="m49.408 5.203c0-.69-.542-1.135-1.529-1.135h-2.106v2.38h2.043c.941-.002 1.592-.428 1.592-1.245zm-12.014.257c0-.858-.651-1.371-1.768-1.371h-2.13v2.791h2.117c1.04 0 1.78-.53 1.78-1.42zm10.564 1.685h-2.185v2.435h2.318c1.058 0 1.696-.463 1.696-1.243s-.625-1.193-1.829-1.193zm8.072-3.059h-1.618v5.466h1.619c1.746 0 2.86-1.185 2.86-2.722s-1.114-2.744-2.86-2.744z" fill="none"/><path d="m1.447 6.777a2.196 2.196 0 0 0 1.768.794 1.892 1.892 0 0 0 1.442-.556 1.852 1.852 0 0 0 .529-1.323 1.587 1.587 0 0 0 -.51-1.246 1.617 1.617 0 0 0 -1.088-.453 2.45 2.45 0 0 0 -.474.061 1.987 1.987 0 0 1 -.378.072q-.42 0-.42-.508 0-.855.851-1.469a4.019 4.019 0 0 1 .51-.312 4.255 4.255 0 0 1 1.76-.35 6.085 6.085 0 0 1 3.44 1.001l-.752 1.08 1.043.687 1.412-2.015-.455-.357a7.295 7.295 0 0 0 -4.696-1.617c-2.751 0-4.675 1.688-4.675 4.106v.172c.003.95.243 1.701.693 2.233zm7.113-.632a1.892 1.892 0 0 0 -1.443.555 1.852 1.852 0 0 0 -.529 1.323 1.587 1.587 0 0 0 .513 1.238 1.617 1.617 0 0 0 1.101.463 2.44 2.44 0 0 0 .474-.06 1.984 1.984 0 0 1 .378-.072q.42 0 .42.508a1.587 1.587 0 0 1 -.039.355c-.466 1.143-1.664 1.799-3.395 1.799a6.712 6.712 0 0 1 -4.027-1.323l.942-1.1-.994-.768-1.696 2.008.43.386a7.972 7.972 0 0 0 5.332 2.02c.222 0 .437 0 .646-.03a5.027 5.027 0 0 0 3.09-1.204q1.237-1.113 1.237-3.077 0-1.423-.672-2.222a2.196 2.196 0 0 0 -1.768-.8z" fill="#1e7b85"/><path d="m41.328 3.366h.78v6.906h-.78zm7.773 3.337a1.672 1.672 0 0 0 1.096-1.609c0-1.058-.89-1.728-2.25-1.728h-2.941v6.906h3.069c1.51 0 2.505-.701 2.505-1.894 0-.969-.661-1.43-1.479-1.675zm-3.328-2.635h2.117c.986 0 1.529.444 1.529 1.135 0 .82-.651 1.243-1.588 1.243h-2.058v-2.381zm2.318 5.506h-2.318v-2.43h2.18c1.204 0 1.836.435 1.836 1.194 0 .76-.648 1.236-1.693 1.236zm-20.421.097c-1.519 0-2.675-1.254-2.675-2.86s1.146-2.841 2.675-2.841a3.003 3.003 0 0 1 2.17.947l.529-.572a3.596 3.596 0 0 0 -2.694-1.095c-2.032 0-3.492 1.609-3.492 3.582s1.482 3.56 3.469 3.56a3.596 3.596 0 0 0 2.775-1.213l-.53-.502c-.658.63-1.28.994-2.227.994zm-7.805-3.225c-1.521-.325-1.873-.69-1.873-1.352 0-.661.569-1.124 1.468-1.124a3.035 3.035 0 0 1 1.992.748l.456-.6a3.604 3.604 0 0 0 -2.427-.85c-1.323 0-2.27.794-2.27 1.895 0 1.1.73 1.659 2.318 2.003 1.45.307 1.796.672 1.796 1.323 0 .7-.61 1.185-1.54 1.185a3.289 3.289 0 0 1 -2.38-.976l-.472.563a4.04 4.04 0 0 0 2.823 1.112c1.371 0 2.358-.77 2.358-1.964 0-1.066-.712-1.627-2.25-1.963zm18.317-1.016c0-1.243-.976-2.06-2.498-2.06h-2.968v6.905h.78v-2.686h1.974l2.003 2.683h.958l-2.117-2.823c1.08-.193 1.868-.865 1.868-2.019zm-4.686 1.45v-2.794h2.133c1.114 0 1.767.513 1.767 1.37s-.743 1.424-1.78 1.424h-2.117zm22.534-3.514h-2.396v6.906h2.397c2.17 0 3.67-1.51 3.67-3.463s-1.493-3.443-3.67-3.443zm0 6.186h-1.618v-5.466h1.619c1.746 0 2.86 1.204 2.86 2.744s-1.114 2.722-2.86 2.722z" fill="#00293f"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="211" height="81" viewBox="0 0 211 81" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M210.119 8.04C207.848 5.49176 199.981 8.53856 195.107 11.0868C190.453 13.5242 186.631 19.5625 186.298 20.061C186.188 19.8394 185.855 19.6179 185.468 19.5625C185.08 19.5071 184.747 19.5625 184.526 19.784C184.415 19.1747 182.864 12.1947 179.319 8.31698C175.607 4.21764 169.292 -1.32201 166.3 0.284491C163.808 1.6694 166.799 6.82127 167.519 10.9206C168.295 15.02 167.63 18.6761 169.957 20.7812C172.062 22.6093 172.782 22.6647 172.893 22.6647C172.339 22.7201 164.029 24.2158 167.796 36.9016C169.236 41.7764 178.211 43.4383 180.593 36.957C181.867 33.5778 182.476 32.5252 182.753 32.4698C182.808 32.8022 182.919 33.0792 183.085 33.0792C183.252 33.1346 183.473 32.913 183.584 32.5806C183.806 32.7468 184.027 33.9102 184.083 37.5663C184.083 44.4909 193.112 45.9866 196.159 41.8872C204.025 31.2511 196.713 27.041 196.214 26.764C196.325 26.8194 197.045 27.041 199.649 25.9885C202.585 24.8251 203.194 21.169 205.355 17.5682C207.405 14.0228 212.002 10.2005 210.119 8.04Z" fill="#4698CB"/>
|
||||||
|
<path d="M16.5635 47.7225C18.2808 47.7225 19.6657 46.393 19.6657 44.6203C19.6657 42.903 18.3362 41.5181 16.5635 41.5181H12.021V33.7626C12.021 31.9899 10.5807 30.4942 8.75264 30.4942C7.03535 30.4942 5.48425 31.9899 5.48425 33.7626V41.5181H3.1022C1.38491 41.5181 0 42.8476 0 44.6203C0 46.3376 1.32952 47.7225 3.1022 47.7225H5.53965V69.2717C5.53965 75.8085 9.63899 80.074 16.0096 80.074C17.7823 80.074 19.1672 78.7445 19.1672 76.9718C19.1672 75.2545 17.7823 73.8696 16.0096 73.8696C13.5167 73.8696 12.021 72.1523 12.021 69.2163V47.7225H16.5635Z" fill="black"/>
|
||||||
|
<path d="M39.6085 74.4236C32.1854 74.4236 28.64 69.8257 28.64 60.4083C28.64 50.9909 32.2407 46.4484 39.6085 46.4484C46.9762 46.4484 50.577 50.9909 50.577 60.4083C50.577 69.8257 46.9762 74.4236 39.6085 74.4236ZM39.6085 40.4102C28.3076 40.4102 22.0478 47.5009 22.0478 60.3529C22.0478 73.2603 28.3076 80.4064 39.6085 80.4064C50.9094 80.4064 57.1692 73.3157 57.1692 60.3529C57.1692 47.5009 50.9094 40.4102 39.6085 40.4102Z" fill="black"/>
|
||||||
|
<path d="M72.3478 73.8696C71.0183 73.8696 69.7996 73.3711 69.7996 69.9365V24.0682C69.7996 22.2955 68.3592 20.7998 66.5312 20.7998C64.8139 20.7998 63.2628 22.2955 63.2628 24.0682V69.9919C63.2628 76.4733 66.4758 80.074 72.2924 80.074C74.0097 80.074 75.3946 78.6891 75.3946 76.9718C75.3946 75.2545 74.0097 73.8696 72.3478 73.8696Z" fill="black"/>
|
||||||
|
<path d="M83.8703 40.9087C82.0422 40.9087 80.6019 42.349 80.6019 44.1771V76.8056C80.6019 78.6337 82.0422 80.074 83.8703 80.074C85.6983 80.074 87.1387 78.6337 87.1387 76.8056V44.1217C87.0833 42.349 85.6429 40.9087 83.8703 40.9087Z" fill="black"/>
|
||||||
|
<path d="M111.292 40.4102C107.192 40.4102 103.481 41.9613 101.265 44.5095V44.1217C101.265 42.2936 99.8244 40.8533 97.9964 40.8533C96.1683 40.8533 94.728 42.2936 94.728 44.1217V76.7503C94.728 78.5783 96.1683 80.0186 97.9964 80.0186C99.8244 80.0186 101.265 78.5783 101.265 76.7503V56.9183C101.265 49.883 104.644 46.3376 111.292 46.3376C118.77 46.3376 120.266 50.3261 120.266 56.3643V76.8056C120.266 78.6337 121.706 80.074 123.479 80.074C125.307 80.074 126.747 78.6337 126.747 76.8056V55.3118C126.747 45.4512 121.54 40.4102 111.292 40.4102Z" fill="black"/>
|
||||||
|
<path d="M150.235 74.4236C142.812 74.4236 139.267 69.8257 139.267 60.4083C139.267 50.9909 142.868 46.4484 150.235 46.4484C157.603 46.4484 161.204 50.9909 161.204 60.4083C161.204 69.8257 157.603 74.4236 150.235 74.4236ZM150.235 40.4102C138.934 40.4102 132.675 47.5009 132.675 60.3529C132.675 73.2603 138.934 80.4064 150.235 80.4064C161.536 80.4064 167.796 73.3157 167.796 60.3529C167.796 47.5009 161.536 40.4102 150.235 40.4102Z" fill="black"/>
|
||||||
|
<path d="M83.8703 27.7244C81.4882 27.7244 80.2141 29.0539 80.2141 31.5467C80.2141 33.818 81.599 35.2029 83.8703 35.2029C86.1415 35.2029 87.5264 33.818 87.5264 31.5467C87.5264 29.0539 86.2523 27.7244 83.8703 27.7244Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4875 4875"><path fill="#0078d4" d="M0 0h2311v2310H0zm2564 0h2311v2310H2564zM0 2564h2311v2311H0zm2564 0h2311v2311H2564"/></svg>
|
||||||
|
After Width: | Height: | Size: 179 B |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,477 @@
|
|||||||
|
---
|
||||||
|
// A faithful CSS mock-up of the Margin desktop app: the All-books sidebar with
|
||||||
|
// front-matter / chapter sections, the writing surface set in Literata with live
|
||||||
|
// proofing, and the live PDF preview dock with its trim selector.
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="win" role="img" aria-label="The Margin app: a chapter sidebar, a writing surface with live proofing, and a live PDF page preview.">
|
||||||
|
<div class="win__bar">
|
||||||
|
<span class="win__chrome"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span>
|
||||||
|
<span class="win__title">The Lighthouse Keeper</span>
|
||||||
|
<span class="win__actions">
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg></span>
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" /></svg></span>
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" /></svg></span>
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" /></svg></span>
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg></span>
|
||||||
|
<span class="ibtn is-on"><svg class="i" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="win__body">
|
||||||
|
<aside class="side">
|
||||||
|
<span class="side__brand">
|
||||||
|
<svg class="i" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>All books
|
||||||
|
</span>
|
||||||
|
<span class="side__cover">
|
||||||
|
<svg class="i" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>Cover
|
||||||
|
</span>
|
||||||
|
<span class="side__add">
|
||||||
|
<svg class="i" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>Add page
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="side__scroll">
|
||||||
|
<div class="side__section">
|
||||||
|
<p class="side__label">Front matter</p>
|
||||||
|
<ul class="side__list">
|
||||||
|
<li class="row"><span class="grip"></span><span class="num"></span><span class="text"><span class="title">Title Page</span></span></li>
|
||||||
|
<li class="row"><span class="grip"></span><span class="num"></span><span class="text"><span class="title">Dedication</span></span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side__section">
|
||||||
|
<p class="side__label">Chapters</p>
|
||||||
|
<ul class="side__list">
|
||||||
|
<li class="row is-active">
|
||||||
|
<span class="grip"><svg class="i" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">1</span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="title">The Cliff</span>
|
||||||
|
<span class="meta">Edited yesterday</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="row"><span class="grip"></span><span class="num">2</span><span class="text"><span class="title">A Light at Sea</span></span></li>
|
||||||
|
<li class="row"><span class="grip"></span><span class="num">3</span><span class="text"><span class="title">Salt and Silence</span></span></li>
|
||||||
|
<li class="row"><span class="grip"></span><span class="num">4</span><span class="text"><span class="title">The Long Night</span></span></li>
|
||||||
|
</ul>
|
||||||
|
<span class="side__newchapter">
|
||||||
|
<svg class="i" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>New chapter
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="page">
|
||||||
|
<div class="page__sheet">
|
||||||
|
<p class="chapno">Chapter 1</p>
|
||||||
|
<h4 class="chaptitle">The Cliff</h4>
|
||||||
|
<p>
|
||||||
|
The lighthouse had stood on the headland for ninety years, and in all that time it had
|
||||||
|
never once gone dark. Mara intended to keep it that way.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
She climbed the iron stair each evening at dusk, counting the
|
||||||
|
<span class="u-spell">hundered</span> and one steps out of habit, and watched the lamp
|
||||||
|
<span class="u-grammar">turns</span> its slow circle across the water.
|
||||||
|
</p>
|
||||||
|
<p>The sea, tonight, was the colour of pewter.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="dock">
|
||||||
|
<div class="dock__head">
|
||||||
|
<span class="dock__label">Preview</span>
|
||||||
|
<span class="dock__trim">
|
||||||
|
5 × 8 in
|
||||||
|
<svg class="i" viewBox="0 0 24 24"><path d="M8 10l4-4 4 4M8 14l4 4 4-4" /></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dock__pages">
|
||||||
|
<div class="dock__page">
|
||||||
|
<p class="pch">Chapter 1</p>
|
||||||
|
<p class="pdt">The Cliff</p>
|
||||||
|
<p class="pp pp--first">
|
||||||
|
The lighthouse had stood on the headland for ninety years, and in all that time it had
|
||||||
|
never once gone dark. Mara intended to keep it that way.
|
||||||
|
</p>
|
||||||
|
<p class="pp">
|
||||||
|
She climbed the iron stair each evening at dusk, counting the hundred and one steps out
|
||||||
|
of habit, and watched the lamp turn its slow circle across the water.
|
||||||
|
</p>
|
||||||
|
<p class="pp">The sea, tonight, was the colour of pewter.</p>
|
||||||
|
<span class="pg">1</span>
|
||||||
|
</div>
|
||||||
|
<div class="dock__page dock__page--peek">
|
||||||
|
<p class="pp pp--first">
|
||||||
|
Morning came grey and slow. She brewed coffee in the keeper's kitchen and listened to the
|
||||||
|
gulls argue over the rocks below.
|
||||||
|
</p>
|
||||||
|
<p class="pp">The radio crackled once, then fell silent.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.win {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--r-xl);
|
||||||
|
background: var(--shell);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
box-shadow: var(--shadow-deep);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.i {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: none;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win__bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
height: 44px;
|
||||||
|
padding-inline: 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--shell);
|
||||||
|
}
|
||||||
|
.win__chrome {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.win__chrome .dot:nth-child(1) {
|
||||||
|
background: #ff5f57;
|
||||||
|
}
|
||||||
|
.win__chrome .dot:nth-child(2) {
|
||||||
|
background: #febc2e;
|
||||||
|
}
|
||||||
|
.win__chrome .dot:nth-child(3) {
|
||||||
|
background: #28c840;
|
||||||
|
}
|
||||||
|
.win__title {
|
||||||
|
grid-column: 2;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.win__actions {
|
||||||
|
grid-column: 3;
|
||||||
|
justify-self: end;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.ibtn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.ibtn.is-on {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-wash);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.win__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 196px 1fr 200px;
|
||||||
|
min-height: 432px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--sidebar);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
padding: 12px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.side__brand,
|
||||||
|
.side__cover,
|
||||||
|
.side__add,
|
||||||
|
.side__newchapter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.side__brand {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
.side__cover {
|
||||||
|
color: var(--ink);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.side__add {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.side__scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.side__section + .side__section {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.side__label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-weight: 650;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
.side__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14px 16px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.row .grip {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
opacity: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.row .grip .i {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
.row .num {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.row .text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.row .title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row .meta {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.row.is-active {
|
||||||
|
background: var(--raised);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 1px 2px rgba(35, 32, 27, 0.08), inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
.row.is-active .grip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.row.is-active .num {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.side__newchapter {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30px 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.page__sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 372px;
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.page__sheet p {
|
||||||
|
margin: 0;
|
||||||
|
text-indent: 1.3em;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
|
}
|
||||||
|
.page__sheet p + p {
|
||||||
|
margin-top: 0.2em;
|
||||||
|
}
|
||||||
|
.chapno {
|
||||||
|
text-indent: 0 !important;
|
||||||
|
text-align: center !important;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin: 0 0 0.5em !important;
|
||||||
|
}
|
||||||
|
.chaptitle {
|
||||||
|
text-indent: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 540;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 1.4em;
|
||||||
|
}
|
||||||
|
.chaptitle + p {
|
||||||
|
text-indent: 0;
|
||||||
|
}
|
||||||
|
.u-spell {
|
||||||
|
text-decoration: wavy underline var(--danger);
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.u-grammar {
|
||||||
|
text-decoration: wavy underline var(--good);
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock {
|
||||||
|
background: var(--shell);
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
padding: 14px 14px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dock__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.dock__label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.dock__trim {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
padding: 3px 5px 3px 8px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--raised);
|
||||||
|
}
|
||||||
|
.dock__trim .i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.dock__pages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.dock__page {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 5 / 8;
|
||||||
|
background: #fcfbf7;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: var(--shadow-page);
|
||||||
|
padding: 18px 16px 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #2a2622;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.dock__page .pch {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 4px;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9b9484;
|
||||||
|
text-align: center;
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
}
|
||||||
|
.dock__page .pdt {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 560;
|
||||||
|
color: #23201b;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 11px;
|
||||||
|
}
|
||||||
|
.dock__page .pp {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 5.6px;
|
||||||
|
line-height: 1.62;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
|
text-indent: 1.3em;
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
color: #2a2622;
|
||||||
|
}
|
||||||
|
.dock__page .pp--first {
|
||||||
|
text-indent: 0;
|
||||||
|
}
|
||||||
|
.dock__page .pg {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 9px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: 5px;
|
||||||
|
color: #b3ab9c;
|
||||||
|
}
|
||||||
|
.dock__page--peek {
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.win__body {
|
||||||
|
grid-template-columns: 1fr 180px;
|
||||||
|
}
|
||||||
|
.side {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.win__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.dock {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
const icons = import.meta.glob("../icons/*.svg", {
|
||||||
|
query: "?raw",
|
||||||
|
import: "default",
|
||||||
|
eager: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
const { name, label, class: cls } = Astro.props;
|
||||||
|
const svg = (icons[`../icons/${name}.svg`] as string | undefined) ?? "";
|
||||||
|
---
|
||||||
|
|
||||||
|
<span class:list={["brand-icon", cls]} role="img" aria-label={label} set:html={svg}></span>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
.brand-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.brand-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
import DownloadButtons from "./DownloadButtons.astro";
|
||||||
|
import OfferBadge from "./OfferBadge.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="section cta">
|
||||||
|
<div class="container cta__inner">
|
||||||
|
<h2>Start your book today.</h2>
|
||||||
|
<p class="lede">
|
||||||
|
Download Margin, open a blank page, and write the first line. When it's done, export it and
|
||||||
|
publish. All of it on your own machine, all of it yours.
|
||||||
|
</p>
|
||||||
|
<div class="cta__btns">
|
||||||
|
<DownloadButtons large />
|
||||||
|
</div>
|
||||||
|
<div class="cta__offer">
|
||||||
|
<OfferBadge />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cta {
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 120% at 50% 120%, var(--paper) 0%, transparent 62%),
|
||||||
|
var(--shell);
|
||||||
|
}
|
||||||
|
.cta__inner {
|
||||||
|
max-width: 640px;
|
||||||
|
margin-inline: auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cta .lede {
|
||||||
|
margin: 18px auto 0;
|
||||||
|
}
|
||||||
|
.cta__btns {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
.cta__offer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
---
|
||||||
|
import { compare, site } from "../data/site";
|
||||||
|
|
||||||
|
const keyFor: Record<string, "margin" | "vellum" | "atticus" | "lacuna"> = {
|
||||||
|
Margin: "margin",
|
||||||
|
Vellum: "vellum",
|
||||||
|
Atticus: "atticus",
|
||||||
|
Lacuna: "lacuna",
|
||||||
|
};
|
||||||
|
|
||||||
|
function cell(value: boolean | "partial") {
|
||||||
|
if (value === true) return { cls: "yes", label: "Yes" };
|
||||||
|
if (value === "partial") return { cls: "partial", label: "Varies" };
|
||||||
|
return { cls: "no", label: "No" };
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="section compare" id="compare">
|
||||||
|
<div class="container">
|
||||||
|
<header class="block-head block-head--center">
|
||||||
|
<p class="eyebrow">How Margin compares</p>
|
||||||
|
<h2>A better way to make a book.</h2>
|
||||||
|
<p class="lede">
|
||||||
|
Atticus, Vellum, and Lacuna are capable tools. Here's where Margin is simply different:
|
||||||
|
offline, on every platform, and yours from $0.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="table-wrap card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="th-feature"></th>
|
||||||
|
{
|
||||||
|
compare.cols.map((c) => (
|
||||||
|
<th class:list={["th-col", { "th-col--us": c === "Margin" }]}>{c}</th>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{
|
||||||
|
compare.rows.map((row) => (
|
||||||
|
<tr>
|
||||||
|
<td class="td-feature">{row.feature}</td>
|
||||||
|
{
|
||||||
|
compare.cols.map((c) => {
|
||||||
|
const v = row[keyFor[c]];
|
||||||
|
const m = cell(v);
|
||||||
|
const isUs = c === "Margin";
|
||||||
|
return (
|
||||||
|
<td class:list={["td-val", { "td-val--us": isUs }]}>
|
||||||
|
<span class:list={["mark", m.cls]} title={m.label}>
|
||||||
|
{m.cls === "yes" ? (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
||||||
|
) : (
|
||||||
|
<span class="bar" aria-hidden="true"></span>
|
||||||
|
)}
|
||||||
|
<span class="sr">{m.label}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="compare__foot">
|
||||||
|
Vellum is Mac-only. Lacuna runs on Mac and Windows. Atticus is cloud-based and asks you to
|
||||||
|
sign in. {site.name} runs offline on all three platforms, with no account at all.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.block-head--center {
|
||||||
|
margin-inline: auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.block-head--center .lede {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 640px;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 15px 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.th-feature,
|
||||||
|
.td-feature {
|
||||||
|
text-align: left;
|
||||||
|
width: 42%;
|
||||||
|
}
|
||||||
|
thead th {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 680;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-top: 18px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
.th-col--us {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--accent-wash);
|
||||||
|
border-radius: var(--r-md) var(--r-md) 0 0;
|
||||||
|
}
|
||||||
|
.td-feature {
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
tbody tr + tr td {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.td-val--us {
|
||||||
|
background: var(--accent-wash);
|
||||||
|
}
|
||||||
|
tbody tr:last-child .td-val--us {
|
||||||
|
border-radius: 0 0 var(--r-md) var(--r-md);
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.mark.yes {
|
||||||
|
background: color-mix(in srgb, var(--good) 16%, transparent);
|
||||||
|
color: var(--good);
|
||||||
|
}
|
||||||
|
.mark.yes svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
width: 12px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--line-strong);
|
||||||
|
}
|
||||||
|
.sr {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.compare__foot {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
import { site, platforms } from "../data/site";
|
||||||
|
import { getRelease } from "../data/release";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
large?: boolean;
|
||||||
|
}
|
||||||
|
const { large = false } = Astro.props;
|
||||||
|
const release = await getRelease();
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="dl">
|
||||||
|
<a
|
||||||
|
class:list={["btn", { "btn--lg": large }]}
|
||||||
|
href={site.releasesUrl}
|
||||||
|
data-download
|
||||||
|
rel="noopener"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||||
|
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
<span data-dl-label>Download {site.name}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="dl__os">
|
||||||
|
{
|
||||||
|
platforms.map((p) => (
|
||||||
|
<a
|
||||||
|
class="dl__chip"
|
||||||
|
href={release.urls[p.id]}
|
||||||
|
data-download-os={p.id}
|
||||||
|
rel="noopener"
|
||||||
|
download
|
||||||
|
aria-label={`Download for ${p.label}`}
|
||||||
|
title={`Download for ${p.label}`}
|
||||||
|
>
|
||||||
|
<img class="dl__logo" src={`/logos/${p.logo}`} alt={p.label} />
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.dl__os {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.dl__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: var(--shadow-page);
|
||||||
|
transition:
|
||||||
|
box-shadow 0.18s var(--ease),
|
||||||
|
transform 0.18s var(--ease);
|
||||||
|
}
|
||||||
|
.dl__chip:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
}
|
||||||
|
.dl__logo {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
import { site, platforms } from "../data/site";
|
||||||
|
import { getRelease } from "../data/release";
|
||||||
|
|
||||||
|
const { repo, releasesUrl } = site;
|
||||||
|
const { urls, version } = await getRelease();
|
||||||
|
const matches = Object.fromEntries(platforms.map((p) => [p.id, p.match]));
|
||||||
|
---
|
||||||
|
|
||||||
|
<script define:vars={{ repo, releasesUrl, urls, version, matches }}>
|
||||||
|
const LABELS = { mac: "macOS", windows: "Windows", linux: "Linux" };
|
||||||
|
|
||||||
|
function detectOS() {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const plat = ((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "").toLowerCase();
|
||||||
|
if (/mac|iphone|ipad|ipod/i.test(ua) || plat.includes("mac")) return "mac";
|
||||||
|
if (/win/i.test(ua) || plat.includes("win")) return "windows";
|
||||||
|
if (/linux|x11|cros/i.test(ua) || plat.includes("linux")) return "linux";
|
||||||
|
return "mac";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAsset(assets, os) {
|
||||||
|
for (const sfx of matches[os] || []) {
|
||||||
|
const hit = assets.find((a) => a.name.toLowerCase().endsWith(sfx.toLowerCase()));
|
||||||
|
if (hit) return hit.browser_download_url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(map, ver) {
|
||||||
|
const os = detectOS();
|
||||||
|
document.querySelectorAll("[data-download]").forEach((el) => {
|
||||||
|
const label = el.querySelector("[data-dl-label]");
|
||||||
|
if (label) label.textContent = "Download for " + LABELS[os];
|
||||||
|
if (map[os]) el.href = map[os];
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-download-os]").forEach((el) => {
|
||||||
|
const url = map[el.dataset.downloadOs];
|
||||||
|
if (url) el.href = url;
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".dl__os [data-download-os]").forEach((el) => {
|
||||||
|
if (el.dataset.downloadOs === os) el.remove();
|
||||||
|
});
|
||||||
|
if (ver) document.querySelectorAll("[data-version]").forEach((el) => (el.textContent = ver));
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(urls, version);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
||||||
|
headers: { Accept: "application/vnd.github+json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const assets = data.assets || [];
|
||||||
|
const fresh = {};
|
||||||
|
for (const os of Object.keys(matches)) fresh[os] = pickAsset(assets, os) || urls[os] || releasesUrl;
|
||||||
|
apply(fresh, data.tag_name || version);
|
||||||
|
} catch (e) {
|
||||||
|
/* keep the build-time links */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||