diff --git a/package.json b/package.json index 4ccbce2..0b34c55 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "tauri": "tauri", "test": "vitest run", "test:watch": "vitest", - "test:ui": "playwright test" + "test:ui": "playwright test", + "fonts:sync": "node node_modules/margin-shared/bin/sync-fonts.mjs .", + "fonts:check": "node node_modules/margin-shared/bin/sync-fonts.mjs . --check" }, "dependencies": { "@tauri-apps/api": "^2", @@ -28,8 +30,10 @@ "highlight.js": "^11.12.0", "katex": "^0.18.4", "lowlight": "^3.3.0", + "margin-shared": "file:../../python/margin/shared", "mdast-util-to-markdown": "^2", "mermaid": "^11.17.1", + "pdfjs-dist": "^6.2.108", "react": "^19.1.0", "react-dom": "^19.1.0", "remark-frontmatter": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff468f7..f97d2a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,12 +50,18 @@ importers: lowlight: specifier: ^3.3.0 version: 3.3.0 + margin-shared: + specifier: file:../../python/margin/shared + version: file:../../python/margin/shared mdast-util-to-markdown: specifier: ^2 version: 2.1.2 mermaid: specifier: ^11.17.1 version: 11.17.1 + pdfjs-dist: + specifier: ^6.2.108 + version: 6.2.108 react: specifier: ^19.1.0 version: 19.2.8 @@ -402,6 +408,76 @@ packages: '@mermaid-js/parser@1.2.1': resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + '@napi-rs/canvas-android-arm64@1.0.8': + resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.8': + resolution: {integrity: sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.8': + resolution: {integrity: sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.8': + resolution: {integrity: sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.8': + resolution: {integrity: sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@1.0.8': + resolution: {integrity: sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.8': + resolution: {integrity: sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@1.0.8': + resolution: {integrity: sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@1.0.8': + resolution: {integrity: sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.8': + resolution: {integrity: sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.8': + resolution: {integrity: sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.8': + resolution: {integrity: sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==} + engines: {node: '>= 10'} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -1370,6 +1446,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + margin-shared@file:../../python/margin/shared: + resolution: {directory: ../../python/margin/shared, type: directory} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -1538,6 +1618,10 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdfjs-dist@6.2.108: + resolution: {integrity: sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==} + engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2112,6 +2196,54 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@napi-rs/canvas-android-arm64@1.0.8': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.8': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.8': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.8': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.8': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.8': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.8': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.8': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.8': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.8': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.8': + optional: true + + '@napi-rs/canvas@1.0.8': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.8 + '@napi-rs/canvas-darwin-arm64': 1.0.8 + '@napi-rs/canvas-darwin-x64': 1.0.8 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.8 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.8 + '@napi-rs/canvas-linux-arm64-musl': 1.0.8 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.8 + '@napi-rs/canvas-linux-x64-gnu': 1.0.8 + '@napi-rs/canvas-linux-x64-musl': 1.0.8 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.8 + '@napi-rs/canvas-win32-x64-msvc': 1.0.8 + optional: true + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -3072,6 +3204,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + margin-shared@file:../../python/margin/shared: {} + markdown-table@3.0.4: {} marked@16.4.2: {} @@ -3450,6 +3584,10 @@ snapshots: pathval@2.0.1: {} + pdfjs-dist@6.2.108: + optionalDependencies: + '@napi-rs/canvas': 1.0.8 + picocolors@1.1.1: {} picomatch@4.0.5: {} diff --git a/public/fonts/EBGaramond-Italic-VF.ttf b/public/fonts/EBGaramond-Italic-VF.ttf new file mode 100644 index 0000000..f9ad996 Binary files /dev/null and b/public/fonts/EBGaramond-Italic-VF.ttf differ diff --git a/public/fonts/EBGaramond-OFL.txt b/public/fonts/EBGaramond-OFL.txt new file mode 100644 index 0000000..c1ec5e1 --- /dev/null +++ b/public/fonts/EBGaramond-OFL.txt @@ -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. diff --git a/public/fonts/EBGaramond-VF.ttf b/public/fonts/EBGaramond-VF.ttf new file mode 100644 index 0000000..52d34b8 Binary files /dev/null and b/public/fonts/EBGaramond-VF.ttf differ diff --git a/public/fonts/Fraunces-Italic-VF.ttf b/public/fonts/Fraunces-Italic-VF.ttf new file mode 100644 index 0000000..2ddf59a Binary files /dev/null and b/public/fonts/Fraunces-Italic-VF.ttf differ diff --git a/public/fonts/Fraunces-OFL.txt b/public/fonts/Fraunces-OFL.txt new file mode 100644 index 0000000..22fa7ce --- /dev/null +++ b/public/fonts/Fraunces-OFL.txt @@ -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. diff --git a/public/fonts/Fraunces-VF.ttf b/public/fonts/Fraunces-VF.ttf new file mode 100644 index 0000000..8210f94 Binary files /dev/null and b/public/fonts/Fraunces-VF.ttf differ diff --git a/public/fonts/HankenGrotesk-OFL.txt b/public/fonts/HankenGrotesk-OFL.txt new file mode 100644 index 0000000..9c8185d --- /dev/null +++ b/public/fonts/HankenGrotesk-OFL.txt @@ -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. diff --git a/public/fonts/Literata-OFL.txt b/public/fonts/Literata-OFL.txt new file mode 100644 index 0000000..9d3b4e3 --- /dev/null +++ b/public/fonts/Literata-OFL.txt @@ -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. diff --git a/public/fonts/Lora-Italic-VF.ttf b/public/fonts/Lora-Italic-VF.ttf new file mode 100644 index 0000000..e973ccf Binary files /dev/null and b/public/fonts/Lora-Italic-VF.ttf differ diff --git a/public/fonts/Lora-OFL.txt b/public/fonts/Lora-OFL.txt new file mode 100644 index 0000000..d0ce7fe --- /dev/null +++ b/public/fonts/Lora-OFL.txt @@ -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. diff --git a/public/fonts/Lora-VF.ttf b/public/fonts/Lora-VF.ttf new file mode 100644 index 0000000..ee1914c Binary files /dev/null and b/public/fonts/Lora-VF.ttf differ diff --git a/public/fonts/SourceSerif4-Italic-VF.ttf b/public/fonts/SourceSerif4-Italic-VF.ttf new file mode 100644 index 0000000..ccb969a Binary files /dev/null and b/public/fonts/SourceSerif4-Italic-VF.ttf differ diff --git a/public/fonts/SourceSerif4-OFL.txt b/public/fonts/SourceSerif4-OFL.txt new file mode 100644 index 0000000..318074e --- /dev/null +++ b/public/fonts/SourceSerif4-OFL.txt @@ -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. diff --git a/public/fonts/SourceSerif4-VF.ttf b/public/fonts/SourceSerif4-VF.ttf new file mode 100644 index 0000000..40bbb58 Binary files /dev/null and b/public/fonts/SourceSerif4-VF.ttf differ diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index f90ea2b..5432fae 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 7f774e0..70b0d1d 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index a989560..82d6a82 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index 62fc515..4a12787 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 990e79a..3cb2c42 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index de000bb..4b79fcf 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 2e21fbf..0210e2e 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 160e6c4..bd270ed 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index fd822a3..858a3e1 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index cb53690..811ef5a 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 05e9f43..ebe1306 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index ae30092..f72e33f 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 60eb23f..3d5246d 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 43fc441..b075fdf 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index f9582f6..706c1eb 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 7a2bb6c..c3a74e0 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 7bdf70c..72603ec 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/margin-docs.svg b/src-tauri/icons/margin-docs.svg index 7608e41..6c8ee77 100644 --- a/src-tauri/icons/margin-docs.svg +++ b/src-tauri/icons/margin-docs.svg @@ -1,12 +1,14 @@ - - - - - - - + + + + + + + + + diff --git a/src-tauri/src/fonts.rs b/src-tauri/src/fonts.rs index a8aea8b..1c68f11 100644 --- a/src-tauri/src/fonts.rs +++ b/src-tauri/src/fonts.rs @@ -141,3 +141,42 @@ fn collect_installed( .map(|family| (*family).to_string()) .collect() } + +// ------------------------------------------------------------------------------------------------ +// The document's own face +// +// Everything above is about the two families the bundle is missing. What follows is about the one +// the author chose: src/model/fonts.ts offers six bundled families and whatever this machine has, +// and an export has to be handed the bytes of whichever it names. +// ------------------------------------------------------------------------------------------------ + +/// Every family this machine has, for the "System" group of the font picker. +/// +/// Sorted and deduplicated here rather than in the UI, because a font book has one entry per face +/// and the picker wants one per family: a machine with the full Helvetica Neue family would +/// otherwise show it nine times. +#[tauri::command(async)] +pub fn fonts_list_system() -> Vec { + let db = system_db(); + let mut names: Vec = db + .faces() + .filter_map(|face| face.families.first().map(|(name, _)| name.clone())) + .collect(); + names.sort(); + names.dedup(); + names +} + +/// The upright and italic cuts of one installed family, for a document set in a face off the +/// machine. +/// +/// Four queries and not two, because a family whose bold is a separate file gives fontdb a +/// different id for it and the collection has to be loaded either way. `seen` is what stops a `.ttc` +/// holding all four from being read four times. +pub fn system_faces(family: &str) -> Vec> { + let db = system_db(); + let mut fonts = Vec::new(); + let mut seen = HashSet::new(); + faces_for(&db, family, &mut fonts, &mut seen); + fonts +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f7c58f0..4a5a39e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -297,6 +297,7 @@ pub fn run() { pdf::pdf_write, grammar::grammar_available, grammar::grammar_check, + fonts::fonts_list_system, ]) .run(context) .expect("error while running Margin Docs"); diff --git a/src-tauri/src/pdf.rs b/src-tauri/src/pdf.rs index c488c9d..a8afadc 100644 --- a/src-tauri/src/pdf.rs +++ b/src-tauri/src/pdf.rs @@ -41,6 +41,46 @@ static FACES: [&[u8]; 9] = [ include_bytes!("../fonts/HankenGrotesk-BoldItalic.ttf"), ]; +// The other four families src/model/fonts.ts offers, in the variable files public/fonts already +// ships for the editor, and loaded only when a document is actually set in one. +// +// These carry the caveat the nine above exist to avoid, and it is not worth cutting eight more +// static instances to fix. Typst lays a variable file out at its default instance, so a document +// set in EB Garamond exports with its headings at the same weight as its body: the face is right, +// the hierarchy is flatter than the screen. The two the app defaults to are the two that export +// perfectly, and choosing one of these is choosing a face rather than a fallback. +static EB_GARAMOND: [&[u8]; 2] = [ + include_bytes!("../../public/fonts/EBGaramond-VF.ttf"), + include_bytes!("../../public/fonts/EBGaramond-Italic-VF.ttf"), +]; +static LORA: [&[u8]; 2] = [ + include_bytes!("../../public/fonts/Lora-VF.ttf"), + include_bytes!("../../public/fonts/Lora-Italic-VF.ttf"), +]; +static SOURCE_SERIF: [&[u8]; 2] = [ + include_bytes!("../../public/fonts/SourceSerif4-VF.ttf"), + include_bytes!("../../public/fonts/SourceSerif4-Italic-VF.ttf"), +]; +static FRAUNCES: [&[u8]; 2] = [ + include_bytes!("../../public/fonts/Fraunces-VF.ttf"), + include_bytes!("../../public/fonts/Fraunces-Italic-VF.ttf"), +]; + +/// The bytes behind one `BundledFont` id from src/model/fonts.ts. +/// +/// Literata and Hanken Grotesk are absent on purpose rather than missing: `FACES` already holds +/// their nine static instances and they are loaded for every export, so naming either here would +/// hand Typst the same family twice and let the variable copy win. +fn bundled_faces(id: &str) -> &'static [&'static [u8]] { + match id { + "eb-garamond" => &EB_GARAMOND, + "lora" => &LORA, + "source-serif" => &SOURCE_SERIF, + "fraunces" => &FRAUNCES, + _ => &[], + } +} + // mitex 0.2.5, vendored under src-tauri/vendor/mitex with its LICENSE, and served as ordinary // paths under `/mitex/` rather than through Typst's package system. A package spec would mean a // download on first export and a cache directory to keep, for a dependency that is 380K and never @@ -242,7 +282,7 @@ fn compile_once( Ok((bytes, warnings.into_iter().collect())) } -/// Compiles Typst source to PDF bytes, with whatever the compiler had to work around. +/// Compiles Typst source to PDF bytes, in the app's default pair of faces. /// /// Separate from the command so a test can reach it: a `#[tauri::command]` taking an `AppHandle` /// needs a running app, and none of the work below wants one. @@ -250,6 +290,22 @@ pub fn compile( source: String, images: &[ImageInput], root_paths: &[String], +) -> Result<(Vec, Vec), String> { + compile_in(source, images, root_paths, &[], &[]) +} + +/// The same, for a document set in something other than the default. +/// +/// `bundled` are ids from src/model/fonts.ts and `system` are family names off this machine, both +/// as the converter named them in the preamble it wrote. Naming a face Typst has no bytes for is a +/// warning and a fallback rather than a failure, so an id this build does not know and a family +/// that has since been uninstalled both end in a PDF. +pub fn compile_in( + source: String, + images: &[ImageInput], + root_paths: &[String], + bundled: &[String], + system: &[String], ) -> Result<(Vec, Vec), String> { let mut warnings: Vec = Vec::new(); @@ -273,6 +329,16 @@ pub fn compile( let mut fonts: Vec> = FACES.iter().map(|face| face.to_vec()).collect(); fonts.append(&mut fallbacks.fonts); + // The face the author chose, if it is not one of the two already in `FACES`. A system family is + // read off this machine, which is the one place an export can differ between two computers, and + // is the caveat the picker states next to the "System" group. + for id in bundled { + fonts.extend(bundled_faces(id).iter().map(|face| face.to_vec())); + } + for family in system { + fonts.extend(crate::fonts::system_faces(family)); + } + let source = format!("{}{source}", font_preamble(&fallbacks)); let failure = match compile_once(&source, MITEX_LIB, &binaries, &fonts) { @@ -322,6 +388,8 @@ pub fn pdf_compile( app: tauri::AppHandle, source: String, images: Vec, + bundled_fonts: Vec, + system_fonts: Vec, ) -> Result { // The same gate `fs::checked` puts in front of every other read, reached the same way it is: // the open roots out of shared state, then `resolve_in_roots`. The lock is dropped before the @@ -332,7 +400,7 @@ pub fn pdf_compile( open.iter().map(|root| root.path.clone()).collect() }; - let (bytes, warnings) = compile(source, &images, &root_paths)?; + let (bytes, warnings) = compile_in(source, &images, &root_paths, &bundled_fonts, &system_fonts)?; if !warnings.is_empty() { app.emit("pdf-warnings", warnings).ok(); } diff --git a/src-tauri/tests/pdf.rs b/src-tauri/tests/pdf.rs index cd6d413..dcd62d7 100644 --- a/src-tauri/tests/pdf.rs +++ b/src-tauri/tests/pdf.rs @@ -6,7 +6,7 @@ use base64::engine::general_purpose::STANDARD; use base64::Engine; use margin_docs_lib::dto::ImageInput; -use margin_docs_lib::pdf::compile; +use margin_docs_lib::pdf::{compile, compile_in}; use tempfile::TempDir; /// A real 4x4 PNG, so an image that is meant to be read is one Typst can actually decode, and one @@ -314,3 +314,49 @@ fn every_weight_the_converter_asks_for_has_a_face_of_its_own() { } } } + +/// A document set in one of the four extra bundled families really is typeset in it. +/// +/// The bytes are compiled into the binary but only loaded when the front end names the id, so the +/// thing worth proving is that naming it changes the page: the same word in EB Garamond and in the +/// default face has to be two different documents. A family Typst cannot see is not an error, it is +/// a warning and a fallback, so the negative case would pass without the loading working at all. +#[test] +fn a_document_set_in_a_bundled_face_is_typeset_in_it() { + let (_dir, roots) = root(); + let word = |family: &str, bundled: &[String]| { + let source = format!("{PREAMBLE}#text(font: \"{family}\")[Margin]\n"); + compile_in(source, &[], &roots, bundled, &[]).expect("the face typesets").0 + }; + + let default = word("Literata", &[]); + for (id, family) in [ + ("eb-garamond", "EB Garamond"), + ("lora", "Lora"), + ("source-serif", "Source Serif 4"), + ("fraunces", "Fraunces"), + ] { + let chosen = word(family, &[id.to_string()]); + assert_ne!(default, chosen, "{family} came out as the default face"); + } +} + +/// A face the compiler cannot find costs a warning and never the export. +/// +/// Both halves reach here in the ordinary run of things. A bundled id is a preference written by +/// some other version of the app, and a system family is one the user has uninstalled since they +/// chose it; neither is a reason to refuse somebody their PDF. +#[test] +fn a_face_that_is_not_there_still_exports() { + let (_dir, roots) = root(); + let source = format!("{PREAMBLE}#text(font: \"Nothing Installed\")[Margin]\n"); + let (bytes, _) = compile_in( + source, + &[], + &roots, + &["a-face-no-build-has".to_string()], + &["Nothing Installed".to_string()], + ) + .expect("an unknown family falls back rather than failing"); + assert!(bytes.starts_with(b"%PDF-"), "the export still happened"); +} diff --git a/src/App.tsx b/src/App.tsx index b358c07..53f8351 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { Backlinks } from "./components/Backlinks"; import { CommandPalette } from "./components/CommandPalette"; import { ConflictDialog } from "./components/ConflictDialog"; +import { ExportPreview } from "./components/ExportPreview"; import { FindBar } from "./components/FindBar"; import { FindInFiles } from "./components/FindInFiles"; import { ProofPopover } from "./components/ProofPopover"; @@ -36,6 +37,7 @@ import { handleMenuAction } from "./keys/menu"; import { openLink } from "./links"; import { documentKindForPath } from "./model/doc"; import { useDocument } from "./store/useDocument"; +import { useDocumentFonts } from "./store/useDocumentFonts"; import { notify } from "./store/useToast"; import { useWorkspace } from "./store/useWorkspace"; import { startUpdateChecks } from "./update"; @@ -97,6 +99,14 @@ function App() { }; }, []); + // The face the page is set in belongs to the document rather than to the app, so it is applied + // here, on the path, rather than restored once at boot the way the theme and the width are. A + // closed document takes the app back to its default pair, which is what the empty pane behind it + // is drawn in anyway. + useEffect(() => { + useDocumentFonts.getState().openFor(path); + }, [path]); + useEffect(() => { const stops = [ onCommand("editor-width-narrow", () => applyWidth("narrow")), @@ -176,6 +186,7 @@ function App() { + diff --git a/src/api/fonts.ts b/src/api/fonts.ts new file mode 100644 index 0000000..97c61e4 --- /dev/null +++ b/src/api/fonts.ts @@ -0,0 +1,14 @@ +// The machine's font book, for the half of the font picker this app does not ship. + +import { call } from "../ipc"; + +/** + * Every family installed on this machine, sorted and deduplicated by the backend. + * + * Answers with an empty list rather than rejecting when there is no backend to ask, because the + * picker's bundled half works perfectly well without this one and an empty "System" group is the + * honest way to say the machine was not asked. The list is worth caching in the caller: it walks + * several directories on the Rust side and cannot change while the app is running. + */ +export const fontsListSystem = (): Promise => + call("fonts_list_system").catch(() => []); diff --git a/src/api/pdf.ts b/src/api/pdf.ts index 79718ce..6d1698b 100644 --- a/src/api/pdf.ts +++ b/src/api/pdf.ts @@ -1,13 +1,26 @@ // PDF export. The source is built here and compiled there; these two calls are the boundary. import { call, type ImageInput } from "../ipc"; +import { fontsUsed, type DocumentFonts } from "../model/fonts"; /** * Compiles Typst source to PDF bytes. Warnings do not come back through the return value, because * the answer is raw bytes and has no room for a second one: they arrive on `pdf-warnings` instead. + * + * `fonts` is the document's own pair, and what crosses the boundary is which faces to load rather + * than their bytes: a bundled id the backend already has compiled in, a system family name it can + * look up. The source names the families itself, so this only has to make sure the compiler can + * see them. */ -export const pdfCompile = (source: string, images: readonly ImageInput[]) => - call("pdf_compile", { source, images }); +export const pdfCompile = (source: string, images: readonly ImageInput[], fonts: DocumentFonts) => { + const used = fontsUsed(fonts); + return call("pdf_compile", { + source, + images, + bundledFonts: used.bundled, + systemFonts: used.system, + }); +}; /** * Writes the finished file wherever the native save panel pointed. diff --git a/src/components/DocumentSetup.tsx b/src/components/DocumentSetup.tsx new file mode 100644 index 0000000..08f76a1 --- /dev/null +++ b/src/components/DocumentSetup.tsx @@ -0,0 +1,280 @@ +// Document setup: the panel the filename in the title bar opens. +// +// The sibling book app puts the same thing behind the same gesture. Its title bar shows the book's +// title, clicking it opens Book setup, and the faces the book is set in are a section of that +// panel. This is that panel for a document editor, so the two apps answer a click on the title the +// same way, and the two font pickers are the same picker. +// +// What a document has to set up is a shorter list than a book's. There is no author, no ISBN and no +// trim size, because none of those are things a markdown file holds: its metadata is frontmatter, +// which src/model/doc.ts carries opaque and never parses. What is left is the two things that are +// genuinely the document's own, its name on disk and the faces it is set in. +// +// A draft, applied on Save, which is again the sibling's shape. Nothing here touches the open +// document until the button is pressed: changing a face and pressing Escape leaves the page exactly +// as it was. The specimen at the foot of the panel is what stands in for a live preview, and it is +// the better trade for a rename, where "applied as you type" would mean a file moving on disk once +// per keystroke. + +import { useEffect, useRef, useState } from "react"; +import { icons } from "margin-shared"; +import { useEscapeLayer } from "../escape"; +import { useKeyContext } from "../keys/keymap"; +import { + BUNDLED_FONTS, + DEFAULT_FONTS, + FONT_PAIRINGS, + decodeRef, + encodeRef, + fontStack, + fontsEqual, + pairingFor, + type DocumentFonts, + type FontRef, +} from "../model/fonts"; +import { fontsListSystem } from "../api/fonts"; +import { useDocumentFonts } from "../store/useDocumentFonts"; +import { notify } from "../store/useToast"; +import { useWorkspace } from "../store/useWorkspace"; +import { splitExtension } from "./FileTree"; +import { Icon } from "./Icon"; + +/** + * The machine's font book, asked once per launch and kept for every later open of this panel. + * + * It walks several directories on the Rust side and cannot change while the app is running, so a + * second ask would be the same list at the same cost. + */ +let systemFontCache: string[] | null = null; + +interface Draft { + name: string; + fonts: DocumentFonts; +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + {label} + {children} + + ); +} + +/** + * One slot's picker: the six that travel with the app, then whatever this machine has. + * + * A stored family the machine no longer has is put back at the top of its own group rather than + * dropped, so the select shows what the document actually asks for instead of silently reading as + * some other face. + */ +function FontSelect({ + value, + system, + onChange, +}: { + value: FontRef; + system: readonly string[]; + onChange: (ref: FontRef) => void; +}) { + const missing = value.kind === "system" && !system.includes(value.family) ? [value.family] : []; + return ( + + ); +} + +export function DocumentSetup({ path, onClose }: { path: string; onClose: () => void }) { + const fonts = useDocumentFonts((s) => s.fonts); + const setFonts = useDocumentFonts((s) => s.setFonts); + const renameEntry = useWorkspace((s) => s.renameEntry); + + const fileName = path.slice(path.lastIndexOf("/") + 1); + const { base, hidden } = splitExtension(fileName); + + const [draft, setDraft] = useState(() => ({ name: base, fonts })); + const [system, setSystem] = useState(() => systemFontCache ?? []); + // Open on the two selects when the document is in a pair no preset names, because that is the + // state somebody arrived at by using them and the panel should not hide the controls that got + // them there. + const [advanced, setAdvanced] = useState(() => pairingFor(fonts) === null); + const nameRef = useRef(null); + + useEscapeLayer(true, onClose); + useKeyContext("overlay"); + + useEffect(() => { + nameRef.current?.focus(); + nameRef.current?.select(); + }, []); + + useEffect(() => { + if (systemFontCache) return; + let live = true; + void fontsListSystem().then((list) => { + systemFontCache = list; + if (live) setSystem(list); + }); + return () => { + live = false; + }; + }, []); + + const set = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); + const setSlot = (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 = () => { + if (!fontsEqual(draft.fonts, fonts)) setFonts(draft.fonts); + + // The rename goes last and the panel closes either way. A file that could not be renamed is a + // toast and not a reason to throw away the face the user just chose, and the two are unrelated + // enough that failing one must not roll back the other. + const trimmed = draft.name.trim(); + if (trimmed && trimmed !== base) { + renameEntry(path, `${trimmed}${hidden}`).catch((e) => + notify(`Could not rename: ${String(e)}`), + ); + } + onClose(); + }; + + return ( +
+
e.stopPropagation()} + > +
+

Document setup

+ +
+ +
+ {/* The file on disk, and the only thing that ever renames it. A document's H1 is content + and is left alone: a path is what git, every other editor and every relative link from + another document already agreed on. Markdown's own extension is hidden here the same + way the file tree hides it, and put back on the way out. */} + + set({ name: e.target.value })} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + save(); + }} + /> + + +
+ Typography +
+ {FONT_PAIRINGS.map((p) => ( + + ))} +
+ +
+ + {advanced && ( + <> + + setSlot("body", ref)} + /> + + + setSlot("heading", ref)} + /> + + {usesSystem && ( +

+ A font off this machine looks right here and in a PDF exported here, but it is not + part of the app, so another computer may not have it. +

+ )} + + )} + + {/* A heading and a line of prose in the pair being chosen, which is the smallest sample + that shows both slots and the relationship between them. */} +
+ + What lives where + + She read the first line twice, and the whole quiet house seemed to lean in to listen. +
+
+ +
+ {!fontsEqual(draft.fonts, DEFAULT_FONTS) && ( + + )} + +
+
+
+ ); +} diff --git a/src/components/ExportPreview.tsx b/src/components/ExportPreview.tsx new file mode 100644 index 0000000..06957c0 --- /dev/null +++ b/src/components/ExportPreview.tsx @@ -0,0 +1,475 @@ +// The PDF preview: what Cmd-E opens now, and where Save PDF lives. +// +// The sibling book app answers Export with this same panel, and both apps answer it this way for +// the same reason. A PDF is the one thing either of them produces that its author cannot see +// before it exists: the editor draws a document on a scrolling sheet with no page breaks in it, and +// where the pages fall, whether a diagram drew, whether a formula typeset and whether a table fits +// the measure are all only knowable after the compiler has run. Sending that straight to a save +// panel means the first look at the result happens in Preview.app, after a file has been written. +// So the compile happens first, the pages go on screen, and the save panel is a button somebody +// presses once they have seen what they are about to write. +// +// It is drawn over the editor pane rather than over the window, which is the sibling's shape too: +// the sidebar stays where it is, and the pages appear where the page was. That is also why the +// panel measures `.editor-pane` and pins itself to it instead of being a child of it. The pane +// scrolls, and a child of a scrolling container that has to stay still is a fight with the +// scrollbar rather than a layout. +// +// The document cannot change under a preview: nothing in this panel edits, and the pane behind it +// is covered. The sidebar is not, so switching documents closes the panel rather than leaving the +// pages of one document over the name of another. + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist"; +import { icons } from "margin-shared"; +import { useEscapeLayer } from "../escape"; +import { compile, save, suggestedName, type CompiledPdf } from "../export/run"; +import { onCommand } from "../keys/commands"; +import { useKeyContext } from "../keys/keymap"; +import { useDocument } from "../store/useDocument"; +import { notify } from "../store/useToast"; +import { Icon } from "./Icon"; + +/** + * pdf.js, fetched the first time somebody previews something and kept for every preview after. + * + * Imported here rather than at the top of the file because it is a megabyte and a half of reader + * and a separate worker, and an editor that has not been asked for a PDF has no use for either. It + * is the same call mermaid gets in src/export/typst.ts, for the same reason. The types above are + * `import type` and cost nothing: they are erased before anything runs. + * + * The worker is a bundled asset URL rather than a path, so the bundler emits it and the app's CSP + * sees it as its own origin. `worker-src 'self' blob:` in tauri.conf.json is what lets pdf.js + * start it at all. + */ +type Pdfjs = typeof import("pdfjs-dist"); + +let reader: Promise | null = null; + +function loadPdfjs(): Promise { + reader ??= (async () => { + const [pdfjs, worker] = await Promise.all([ + import("pdfjs-dist"), + import("pdfjs-dist/build/pdf.worker.min.mjs?url"), + ]); + pdfjs.GlobalWorkerOptions.workerSrc = worker.default; + return pdfjs; + })(); + return reader; +} + +const ZOOM_MIN = 0.5; +const ZOOM_MAX = 3; +const ZOOM_STEP = 0.25; + +/** Zoom glyphs, which are a minus and a plus and are not worth a name in margin-shared. */ +const MINUS = "M5 12h14"; +const PLUS = "M12 5v14M5 12h14"; + +/** How far outside the viewport a page is drawn before it is scrolled to. */ +const AHEAD = "1400px 0px"; + +interface Frame { + left: number; + top: number; + width: number; + height: number; +} + +function measurePane(): Frame | null { + const pane = document.querySelector(".editor-pane"); + if (!pane) return null; + const r = pane.getBoundingClientRect(); + return { + left: Math.round(r.left), + top: Math.round(r.top), + width: Math.round(r.width), + height: Math.round(r.height), + }; +} + +/** + * The mount, which is one `onCommand` and nothing else. + * + * src/keys/commands.ts dispatches `export-pdf` once it has checked there is a document and a window + * to put a file in, exactly as it does for quick open and the command palette. Nothing about the + * panel's existence is a precondition for that table to compile. + */ +export function ExportPreview() { + const [open, setOpen] = useState(false); + const path = useDocument((s) => s.path); + + useEffect(() => onCommand("export-pdf", () => setOpen(true)), []); + useEffect(() => setOpen(false), [path]); + + if (!open || path === null) return null; + return setOpen(false)} />; +} + +function Preview({ path, onClose }: { path: string; onClose: () => void }) { + const [pdf, setPdf] = useState(null); + const [error, setError] = useState(null); + const [pages, setPages] = useState(0); + const [zoom, setZoom] = useState(1); + const [saving, setSaving] = useState(false); + const [frame, setFrame] = useState(measurePane); + const panelRef = useRef(null); + const zoomRef = useRef(zoom); + zoomRef.current = zoom; + const started = useRef(false); + const live = useRef(true); + + useEscapeLayer(true, onClose); + useKeyContext("overlay"); + + // Where the editor pane is, now and whenever it moves. Dragging the sidebar's resize handle + // moves it without the window changing size, hence the observer as well as the resize listener. + useLayoutEffect(() => { + const update = () => setFrame(measurePane()); + update(); + const pane = document.querySelector(".editor-pane"); + const observer = new ResizeObserver(update); + if (pane) observer.observe(pane); + window.addEventListener("resize", update); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + }; + }, []); + + // Once, on the way in. The document behind the panel cannot change while it is up, so there is + // nothing to recompile for, and a compile per keystroke is not what a preview of a fifty page + // document should cost. + // + // Two refs rather than the usual `let cancelled` because of what React's development StrictMode + // does to an effect: it runs it, tears it down and runs it again on the same instance, to shake + // out cleanups that do not clean up. That is the right test for a subscription and the wrong one + // for a typesetter. `started` is what makes the compile happen once per open, and `live` is what + // an in-flight result is checked against, because the plain closure flag would have been set by + // the teardown between the two runs and the only answer this panel ever gets would be dropped. + // Both reset with the component, which is unmounted when the panel closes. + useEffect(() => { + live.current = true; + if (!started.current) { + started.current = true; + compile() + .then((result) => live.current && setPdf(result)) + .catch((e) => live.current && setError(String(e))); + } + return () => { + live.current = false; + }; + }, []); + + // Pinch on a trackpad and Ctrl-scroll on a mouse, which are the two gestures a page of a document + // is expected to answer. Both have to be taken off the window rather than left to the browser: + // the webview's own zoom would scale the chrome around the pages as well. + useEffect(() => { + const el = panelRef.current; + if (!el) return; + const clamp = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)); + const snap = (z: number) => clamp(Math.round(z / ZOOM_STEP) * ZOOM_STEP); + let start = 1; + let accumulated = 0; + + const onWheel = (e: WheelEvent) => { + if (!e.ctrlKey) return; + e.preventDefault(); + accumulated += e.deltaY; + if (accumulated <= -40) { + accumulated = 0; + setZoom((z) => clamp(z + ZOOM_STEP)); + } else if (accumulated >= 40) { + accumulated = 0; + setZoom((z) => clamp(z - ZOOM_STEP)); + } + }; + const onGestureStart = (e: Event) => { + e.preventDefault(); + start = zoomRef.current; + }; + const onGestureChange = (e: Event) => { + e.preventDefault(); + const next = snap(start * (e as unknown as { scale: number }).scale); + setZoom((z) => (z === next ? z : next)); + }; + const onGestureEnd = (e: Event) => e.preventDefault(); + + el.addEventListener("wheel", onWheel, { passive: false }); + el.addEventListener("gesturestart", onGestureStart); + el.addEventListener("gesturechange", onGestureChange); + el.addEventListener("gestureend", onGestureEnd); + return () => { + el.removeEventListener("wheel", onWheel); + el.removeEventListener("gesturestart", onGestureStart); + el.removeEventListener("gesturechange", onGestureChange); + el.removeEventListener("gestureend", onGestureEnd); + }; + }, []); + + // The file that is about to be written, rather than the document it came from. The title bar an + // inch above already says which document this is. + const name = suggestedName(path); + + // Closes on a written file and stays put on a cancelled panel, because cancelling means the user + // is not finished with the preview yet. + const write = async () => { + if (!pdf) return; + setSaving(true); + try { + if (await save(pdf)) onClose(); + } catch (e) { + notify(`Could not export: ${String(e)}`); + } finally { + setSaving(false); + } + }; + + const adjustZoom = (delta: number) => + setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round((z + delta) * 100) / 100))); + + // Under this the bar has to drop the words and keep the buttons, which happens when the sidebar + // is wide and the window is not. + const compact = (frame?.width ?? 1000) < 460; + + return ( +
+
e.stopPropagation()} + > +
+ +
{name}
+ {pages > 0 && !compact && ( + + {pages} {pages === 1 ? "page" : "pages"} + + )} +
+ + {!compact && {zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`}} + +
+ +
+ + {/* What the compiler worked around, said before the file is written rather than after. + These are the same sentences the toast used to carry alone, and they are worth more + here: a diagram that did not draw is a thing to look at on the page below. */} + {pdf && pdf.trouble.length > 0 && ( +
Exported with {pdf.trouble.join("; ")}.
+ )} + + {error !== null ? ( +
+
{error}
+
+ ) : pdf ? ( + + ) : ( +
+
+
+

Typesetting…

+
+
+ )} +
+
+ ); +} + +/** + * The scroller, and the arithmetic that decides how wide a page is drawn. + * + * At zoom 1 a page is whichever of the stage's two dimensions runs out first, so one whole page is + * visible and "Fit" means what it says. Every other zoom is a multiple of that, which is why the + * column is `width: max-content` and the stage scrolls in both directions. + */ +function Pages({ + bytes, + zoom, + onPages, +}: { + bytes: Uint8Array; + zoom: number; + onPages: (n: number) => void; +}) { + const stageRef = useRef(null); + const [doc, setDoc] = useState(null); + const [ratio, setRatio] = useState(null); + const [stage, setStage] = useState({ width: 0, height: 0 }); + + useEffect(() => { + let cancelled = false; + let task: ReturnType | undefined; + void (async () => { + try { + const pdfjs = await loadPdfjs(); + if (cancelled) return; + // A copy, because pdf.js takes ownership of the buffer it is handed and the same bytes + // still have to go through the IPC boundary when Save is pressed. + task = pdfjs.getDocument({ data: bytes.slice() }); + const opened = await task.promise; + if (cancelled) return; + const first = (await opened.getPage(1)).getViewport({ scale: 1 }); + if (cancelled) return; + setDoc(opened); + setRatio(first.height / first.width); + onPages(opened.numPages); + } catch { + // A PDF this build cannot parse still saves: the bytes came from the compiler and the + // reader is the only thing that failed. The stage stays empty and Save stays live. + } + })(); + return () => { + cancelled = true; + void task?.destroy(); + }; + }, [bytes, onPages]); + + useEffect(() => { + const el = stageRef.current; + if (!el) return; + const update = () => setStage({ width: el.clientWidth, height: el.clientHeight }); + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, [doc]); + + const fit = Math.max(240, Math.min(stage.width - 56, (stage.height - 56) / (ratio ?? 1))); + const width = Math.round(fit * zoom); + + return ( +
+ {doc && ratio !== null && stage.width > 0 && ( +
+ {Array.from({ length: doc.numPages }, (_, i) => ( + + ))} +
+ )} +
+ ); +} + +/** + * One page, drawn when it comes near the viewport and thrown away when it leaves. + * + * A hundred page document is a hundred canvases at device resolution, which is more memory than a + * preview is worth, so the holder keeps the page's shape and the canvas inside it comes and goes. + * The shape is the first page's until this one has been opened, which is right for every document + * whose pages are the same size and self-corrects for the ones that are not. + */ +function Page({ + doc, + number, + root, + width, + ratio, +}: { + doc: PDFDocumentProxy; + number: number; + root: HTMLElement | null; + width: number; + ratio: number; +}) { + const holder = useRef(null); + const [near, setNear] = useState(false); + const [shape, setShape] = useState(ratio); + + useEffect(() => { + const el = holder.current; + if (!el) return; + const observer = new IntersectionObserver(([entry]) => setNear(entry.isIntersecting), { + root, + rootMargin: AHEAD, + }); + observer.observe(el); + return () => observer.disconnect(); + }, [root]); + + useEffect(() => { + const el = holder.current; + if (!el) return; + if (!near) { + el.replaceChildren(); + return; + } + let cancelled = false; + let task: RenderTask | undefined; + void (async () => { + try { + const page = await doc.getPage(number); + if (cancelled) return; + const natural = page.getViewport({ scale: 1 }); + setShape(natural.height / natural.width); + // Capped at 2, because a 3x screen would draw nine times the pixels for a difference + // nobody can see on a page of body text. + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const viewport = page.getViewport({ scale: (width / natural.width) * dpr }); + const canvas = document.createElement("canvas"); + canvas.className = "preview-canvas"; + canvas.width = Math.ceil(viewport.width); + canvas.height = Math.ceil(viewport.height); + task = page.render({ canvas, viewport }); + await task.promise; + if (cancelled) return; + el.replaceChildren(canvas); + } catch { + // Cancelled by a zoom that arrived mid-render, or a page that would not draw. The canvas + // already on screen is the better thing to leave up either way. + } + })(); + return () => { + cancelled = true; + task?.cancel(); + }; + }, [near, doc, number, width]); + + return ( +
+ ); +} diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx index 84a4405..9738edb 100644 --- a/src/components/Titlebar.tsx +++ b/src/components/Titlebar.tsx @@ -1,28 +1,43 @@ // The macOS overlay title bar. The traffic lights float over its left end, which is why the row // itself carries `data-tauri-drag-region` and every control inside it does not: an interactive // element that also drags the window swallows its own click. +// +// The glyphs come from margin-shared, which is where the sibling book app takes them from too, so +// that the two do not each have their own idea of what a search or a moon looks like. They are +// paths and not a dependency: an icon set is six hundred kilobytes for the handful of shapes a +// title bar needs. +// +// What is in the row is a shorter list than what the app can do, and the cut is on purpose. A +// button here is for something whose result the user then looks at on this screen: the sidebar, the +// page's measure, its theme, whether it is underlined, where it goes as a PDF. Making a file is not +// that, which is why New Document is a keystroke and a row in the menu at the end and not a button +// of its own. +// +// The filename is the way in to everything the document itself holds, which is what the sibling +// book app does with the title it shows here. Clicking it opens Document setup, where the name is +// the first field and the faces the page is set in are the second, so neither spends a button on +// the row and neither is somewhere a reader would not think to look. -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; +import { icons } from "margin-shared"; import { useEscapeLayer } from "../escape"; import { keyLabel, keysFor } from "../keys/bindings"; import { commandLabel, onCommand, runCommand, type CommandId } from "../keys/commands"; import { useDocument } from "../store/useDocument"; +import { useProofing } from "../store/useProofing"; import { useTheme } from "../store/useTheme"; -import { notify } from "../store/useToast"; -import { useWorkspace } from "../store/useWorkspace"; +import { DocumentSetup } from "./DocumentSetup"; import { splitExtension } from "./FileTree"; import { Icon } from "./Icon"; import { WidthMenu } from "./WidthMenu"; - const SIDEBAR_KEY = "margindocs-sidebar"; -const SIDEBAR_ICON = "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M9 3v18"; -const NEW_DOC = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5"; -const SEARCH = "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z M20 20l-3.6-3.6"; -const SUN = "M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10z M12 1v2 M12 21v2 M4.2 4.2l1.4 1.4 M18.4 18.4l1.4 1.4 M1 12h2 M21 12h2 M4.2 19.8l1.4-1.4 M18.4 5.6l1.4-1.4"; -const MOON = "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"; -const MORE = "M5 12h.01M12 12h.01M19 12h.01"; +// The glyphs themselves live in margin-shared, so the two apps cannot drift into two ideas of what +// a search or a moon looks like. Which of them this row uses, and in what order, is still this +// app's decision. +const { SIDEBAR: SIDEBAR_ICON, SEARCH, SPELLING, GRAMMAR, EXPORT, MOON, MORE, SUN_RAYS, SUN_DISC } = + icons; /** A tooltip that names the action and prints its key in the glyphs the sheet uses. */ export function shortcutTitle(id: CommandId): string { @@ -30,19 +45,36 @@ export function shortcutTitle(id: CommandId): string { return keys.length ? `${commandLabel(id)} (${keyLabel(keys[0])})` : commandLabel(id); } +/** The sun, which is the one glyph here that is not a single path. */ +function Sun() { + return ( + + + + + ); +} + export function Titlebar() { const path = useDocument((s) => s.path); const dirty = useDocument((s) => s.dirty); const externalChange = useDocument((s) => s.externalChange); - const renameEntry = useWorkspace((s) => s.renameEntry); const theme = useTheme((s) => s.theme); const toggleTheme = useTheme((s) => s.toggle); + const spelling = useProofing((s) => s.enabled); + const toggleSpelling = useProofing((s) => s.toggle); + const spellingAvailability = useProofing((s) => s.availability); + const grammar = useProofing((s) => s.grammar); + const toggleGrammar = useProofing((s) => s.toggleGrammar); + const grammarAvailability = useProofing((s) => s.grammarAvailability); + const ensureAvailable = useProofing((s) => s.ensureAvailable); + const [sidebar, setSidebar] = useState( () => document.documentElement.getAttribute("data-sidebar") !== "false", ); const [menu, setMenu] = useState(false); - const [renaming, setRenaming] = useState(false); + const [setup, setSetup] = useState(false); useEffect(() => { document.documentElement.setAttribute("data-sidebar", String(sidebar)); @@ -54,24 +86,22 @@ export function Titlebar() { }, [sidebar]); useEffect(() => onCommand("toggle-sidebar", () => setSidebar((v) => !v)), []); - useEffect(() => setRenaming(false), [path]); + useEffect(() => setSetup(false), [path]); + + // Asked once per launch, and asked here because this is the first thing on screen that has to + // know: a machine with no checker gets no button rather than a button that toggles a setting + // nothing acts on. The store answers each question once whichever of its callers gets there + // first, so this costs nothing when the settings panel or the editor asked already. + useEffect(() => ensureAvailable(), [ensureAvailable]); useEscapeLayer(menu, () => setMenu(false)); + // What the title bar shows is the file on disk, never the document's H1. The two are unrelated + // and this is the place that promise is easiest to break: editing a heading is a content edit and + // nothing else, because a path is what git, every other editor and every relative link from + // another document already agreed on. const fileName = path ? path.slice(path.lastIndexOf("/") + 1) : ""; - const { base, hidden } = splitExtension(fileName); - - // The filename and the document's H1 are unrelated, and this is the place that promise is - // easiest to break. What the title bar shows is the file on disk, and the only thing that ever - // renames it is the user typing here. Editing a heading is a content edit and nothing else: it - // does not move the file, because a path is what git, every other editor and every relative - // link from another document already agreed on. - const commitRename = (next: string) => { - setRenaming(false); - const trimmed = next.trim(); - if (!path || !trimmed || trimmed === base) return; - renameEntry(path, `${trimmed}${hidden}`).catch((e) => notify(`Could not rename: ${String(e)}`)); - }; + const { base } = splitExtension(fileName); const menuItem = (id: CommandId) => (
- {path && - (renaming ? ( - setRenaming(false)} /> - ) : ( - - ))} + {path && ( + + )}
- + + {/* The two checkers, which are two buttons because they are two checkers: spelling is the + system's and grammar is Harper's, and wanting one underlined without the other is not a + strange thing to want. Both are settings rather than actions, so they are pressed + toggles and say so. */} + {canSpell && ( + + )} + {canCheckGrammar && ( + + )} + + {/* The measure, which belongs to the app's view of every document. The face, which belongs + to the one document, is in the setup panel behind the filename instead: that is the + difference between them, and src/store/useDocumentFonts.ts is where it is spelled out. */} + + + + - +
{open && ( <> diff --git a/src/dev/mockIpc.ts b/src/dev/mockIpc.ts index 86e2119..9508949 100644 --- a/src/dev/mockIpc.ts +++ b/src/dev/mockIpc.ts @@ -563,6 +563,12 @@ export async function mockCall(command: string, args?: Record path.slice(path.lastIndexOf("/") + 1) || path; -/** The same name with a .pdf on it, which is what the save panel opens with. */ -function suggestedName(path: string): string { +/** + * The same name with a .pdf on it, which is what the save panel opens with. + * + * Exported because the preview's bar shows it: naming the file that is about to be written is more + * use there than repeating the document name the title bar is already showing an inch above. + */ +export function suggestedName(path: string): string { const name = baseName(path); const dot = name.lastIndexOf("."); return `${dot > 0 ? name.slice(0, dot) : name}.pdf`; @@ -95,79 +118,103 @@ async function compileWithFallback( doc: ProseMirrorNode, path: string, diagrams: Diagrams, + fonts: DocumentFonts, first: TypstDocument, ): Promise<{ bytes: ArrayBuffer; warnings: PdfWarning[]; retried: boolean }> { try { - const attempt = await withWarnings(() => pdfCompile(first.source, first.images)); + const attempt = await withWarnings(() => pdfCompile(first.source, first.images, fonts)); return { bytes: attempt.result, warnings: attempt.warnings, retried: false }; } catch (e) { if (!hasMath(doc)) throw e; - const literal = documentToTypst(doc, path, { diagrams, math: "literal" }); - const attempt = await withWarnings(() => pdfCompile(literal.source, literal.images)); + const literal = documentToTypst(doc, path, { diagrams, math: "literal", fonts }); + const attempt = await withWarnings(() => pdfCompile(literal.source, literal.images, fonts)); return { bytes: attempt.result, warnings: attempt.warnings, retried: true }; } } /** - * The whole export, from the tree in the editor to a file on disk. + * Whether there is anything to export, and the sentence saying why not when there is not. * - * Bound to the `export-pdf` command in src/keys/commands.ts, which is the only caller. + * Asked by the `export-pdf` entry in src/keys/commands.ts before it opens the panel, because a + * panel that opens onto "no document" is a worse answer than never opening. The save panel is a + * window API, so it is not merely absent in a browser, it is refused, and there is no point + * compiling a PDF this build has nowhere to put. */ -export async function exportPdf(): Promise { - if (phase !== "idle") { - notify("An export is already running."); - return; - } - +export function canExport(): boolean { const { path, content } = useDocument.getState(); if (path === null || content === null) { notify("Open a document to export it."); - return; + return false; } - // The save panel is a window API, so it is not merely absent in a browser, it is refused. There - // is no point compiling a PDF this build has nowhere to put. if (!isDesktop) { notify("PDF export needs the desktop app."); - return; + return false; } + return true; +} - phase = "converting"; - try { - // Drawn before the conversion rather than during it, because mermaid is asynchronous and wants - // a DOM, and the converter is neither. A diagram that will not draw is simply missing from the - // map and comes out of the converter as the fence it always was. - const drawings = await renderDiagrams(content); - const undrawn = diagramSources(content).length - drawings.size; +/** + * The document on screen, typeset. + * + * Throws rather than notifying, because its caller is a panel with somewhere to put the message: + * a compile that failed is the whole of what that panel has to show, and a toast behind an empty + * preview would be the wrong place to read it. + */ +export async function compile(): Promise { + const { path, content } = useDocument.getState(); + if (path === null || content === null) throw new Error("no document is open"); - const converted = documentToTypst(content, path, { diagrams: drawings }); + // Drawn before the conversion rather than during it, because mermaid is asynchronous and wants + // a DOM, and the converter is neither. A diagram that will not draw is simply missing from the + // map and comes out of the converter as the fence it always was. + const drawings = await renderDiagrams(content); + const undrawn = diagramSources(content).length - drawings.size; - phase = "compiling"; - const { bytes, warnings, retried } = await compileWithFallback(content, path, drawings, converted); + // The faces the page is in on screen, so an export is the document as its author sees it. + const fonts = useDocumentFonts.getState().fonts; + const converted = documentToTypst(content, path, { diagrams: drawings, fonts }); - phase = "writing"; - const target = await savePanel({ - title: "Export as PDF", - defaultPath: suggestedName(path), - filters: [{ name: "PDF", extensions: ["pdf"] }], - }); - if (target === null) return; + const { bytes, warnings, retried } = await compileWithFallback( + content, + path, + drawings, + fonts, + converted, + ); - await pdfWrite(target, new Uint8Array(bytes)); - - const trouble = [ + return { + path, + bytes: new Uint8Array(bytes), + trouble: [ ...converted.warnings, ...(undrawn > 0 ? [`${undrawn} diagram${undrawn === 1 ? "" : "s"} could not be drawn`] : []), ...(retried ? ["every formula shown as the source it was written in"] : []), ...(warnings.length > 0 ? [describe(warnings)] : []), - ]; - notify( - trouble.length === 0 - ? `Exported ${baseName(target)}` - : `Exported ${baseName(target)}, with ${trouble.join("; ")}`, - ); - } catch (e) { - notify(`Could not export: ${String(e)}`); - } finally { - phase = "idle"; - } + ], + }; +} + +/** + * The native save panel, and the write behind it. + * + * Answers whether a file was written, which is not the same question as whether anything went + * wrong: cancelling the panel is an ordinary way to end an export and gets no toast, because + * nothing happened and the user is the one who decided that. + */ +export async function save(pdf: CompiledPdf): Promise { + const target = await savePanel({ + title: "Export as PDF", + defaultPath: suggestedName(pdf.path), + filters: [{ name: "PDF", extensions: ["pdf"] }], + }); + if (target === null) return false; + + await pdfWrite(target, pdf.bytes); + + notify( + pdf.trouble.length === 0 + ? `Exported ${baseName(target)}` + : `Exported ${baseName(target)}, with ${pdf.trouble.join("; ")}`, + ); + return true; } diff --git a/src/export/typst.ts b/src/export/typst.ts index c13b0ee..ef4eb46 100644 --- a/src/export/typst.ts +++ b/src/export/typst.ts @@ -23,6 +23,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import type { ImageInput } from "../ipc"; import { resolveRelative } from "../links"; +import { DEFAULT_FONTS, fontFamilyName, type DocumentFonts } from "../model/fonts"; import { CALLOUT_LABELS, calloutKindFromLabel, type CalloutKind } from "../model/doc"; export interface TypstDocument { @@ -48,6 +49,10 @@ export type MathMode = "typeset" | "literal"; export interface TypstOptions { diagrams?: Diagrams; math?: MathMode; + /** The two faces the document is set in, which default to what tokens.css sets an untouched one + * in. Only the body and heading slots follow the document: code and formulas are set in the + * machine's own faces either way, which is src-tauri/src/pdf.rs's business and not this one's. */ + fonts?: DocumentFonts; } const MERMAID = "mermaid"; @@ -128,15 +133,20 @@ function flow(value: string): string { // chosen here, because an export is the document on screen and not a second design. The light // palette specifically: a PDF is a white page whatever `data-theme` says. // -// Literata and Hanken Grotesk are the two families src-tauri/src/pdf.rs compiles into the binary, -// so they are always there and can be named here. Monospace and the maths face are not named here -// at all: neither is bundled, both come off the machine, and Typst warns once for every family it -// was asked for and could not find, so a hopeful list written on this side would end every export -// with a toast about fonts nobody chose. src-tauri/src/fonts.rs asks the system what it actually -// has, and pdf.rs puts the answer in front of this preamble. +// The body and heading faces are the document's own and arrive in `TypstOptions`, because a font +// is a per-document setting here the way it is in src/store/useDocumentFonts.ts. Whichever family +// they name, src-tauri/src/pdf.rs is handed its bytes before this preamble reaches the compiler. +// +// Monospace and the maths face are not named here at all: neither is bundled, both come off the +// machine, and Typst warns once for every family it was asked for and could not find, so a hopeful +// list written on this side would end every export with a toast about fonts nobody chose. +// src-tauri/src/fonts.rs asks the system what it actually has, and pdf.rs puts the answer in front +// of this preamble. // ------------------------------------------------------------------------------------------------ -const BODY_FONT = "Literata"; +// The app's own face, and the one face on the page that is not a document decision. prose.css does +// not make it one either: the fifth and sixth heading levels are labels rather than headings, and +// the sheet sets them in the UI family on screen whatever the document is set in. const UI_FONT = "Hanken Grotesk"; // The light palette of src/styles/tokens.css, written out because Typst source cannot read a CSS @@ -183,11 +193,13 @@ const CALLOUT_COLOURS: Record> = { "new-folder": { label: "New Folder", palette: true, run: () => void createFolder() }, "close-folder": { label: "Close Folder", palette: false, run: closeActiveFolder }, save: { label: "Save", palette: true, run: () => void saveDocument() }, - "export-pdf": { label: "Export as PDF…", palette: true, run: () => void exportPdf() }, + // Dispatched rather than run, because what Export produces first is a panel: the pages go on + // screen and the save panel is a button on that panel. The guard stays here, because a preview + // that opens onto "no document open" is a worse answer than one that never opens. + "export-pdf": { + label: "Export as PDF…", + palette: true, + run: () => { + if (canExport()) dispatch("export-pdf"); + }, + }, "rename-file": { label: "Rename", palette: false, run: () => void renameSelected() }, "duplicate-file": { label: "Duplicate", palette: false, run: () => void duplicateSelected() }, "delete-file": { label: "Delete", palette: false, run: () => void deleteSelected() }, diff --git a/src/main.tsx b/src/main.tsx index 1c5722e..0ae776a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,8 +8,8 @@ // order their rules expect to win: the document's typography, then the three block sheets that // answer it for a construct with a node view or a decoration of its own, then the page the document // sits on and the section drawn under the end of it, the pill that floats over that page and the -// two menus that hang off it, and last the shell, which is the one sheet that reaches back into -// the title bar app.css already styled. The palettes come last of all: they override .overlay from +// three menus that hang off the title bar, and last the shell, which is the one sheet that reaches +// back into the title bar app.css already styled. The palettes come last of all: they override .overlay from // app.css and .key-cap from tree.css, so they have to be able to see both. // // katex.min.css is not here. src/editor/blocks/math.ts imports it itself so that the bundler @@ -26,6 +26,8 @@ import "./styles/sheet.css"; import "./styles/backlinks.css"; import "./styles/toolbar.css"; import "./styles/width-menu.css"; +import "./styles/document-setup.css"; +import "./styles/export-preview.css"; import "./styles/link-picker.css"; import "./styles/tree.css"; import "./styles/palette.css"; diff --git a/src/model/fonts.ts b/src/model/fonts.ts new file mode 100644 index 0000000..8625f75 --- /dev/null +++ b/src/model/fonts.ts @@ -0,0 +1,40 @@ +// The faces a document can be set in. +// +// The catalogue itself is margin-shared's, because Margin and Margin Docs have to offer the same +// six families under the same ids: a book and a document set in "EB Garamond" must mean the same +// file, and a stored preference written by one app has to be readable by the other. What is left +// here is the one thing that is this app's own, which is the noun. + +export type { + BundledFont, + FontCategory, + FontPairing, + FontRef, +} from "margin-shared/fonts"; + +export { + BUNDLED_FONTS, + DEFAULT_FONTS, + FONT_PAIRINGS, + bundledFont, + decodeRef, + encodeRef, + fontFamilyName, + fontLabel, + fontStack, + fontsEqual, + fontsUsed, + isFontRef, + pairingFor, + refsEqual, +} from "margin-shared/fonts"; + +import type { FontPair } from "margin-shared/fonts"; + +/** + * What one document is set in. + * + * An alias rather than a second interface, so every call site in this app keeps reading in the + * app's own noun while the shape stays the shared one. Margin calls the same pair `BookFonts`. + */ +export type DocumentFonts = FontPair; diff --git a/src/store/useDocumentFonts.ts b/src/store/useDocumentFonts.ts new file mode 100644 index 0000000..386980f --- /dev/null +++ b/src/store/useDocumentFonts.ts @@ -0,0 +1,153 @@ +// What the open document is set in, remembered per file. +// +// Per file and not per app, which is the whole point of the feature: a set of meeting notes and a +// short story are not the same kind of page, and a preference that applies to both is the one the +// app already made in src/styles/tokens.css. The model is Google Docs', where the face belongs to +// the document rather than to the editor. +// +// Where it is kept, and why it is not the file +// -------------------------------------------- +// A path to a `DocumentFonts`, in localStorage, beside the theme and the width. The obvious other +// home is the document's own frontmatter, and it is deliberately not used: src/model/doc.ts holds +// frontmatter as an opaque string precisely so that the app never re-emits somebody's YAML, and +// writing a key into it would mean parsing and re-serialising the one part of the file this editor +// promises not to touch. A font is also not content. `margin-docs-fonts: literata` committed into a +// repository is a line every other markdown tool has to ignore, and a diff on every document whose +// author tried a face and changed their mind. +// +// The cost is honest and worth stating: this is a preference on this machine, so the same file +// opened on another one, or after the app's storage is cleared, opens in the default pair. Nothing +// is lost when that happens, because a font is not text. +// +// A renamed or moved file loses its entry, for the same reason: the key is the path, and the app +// has no identity for a document other than where it lives. + +import { create } from "zustand"; +import { + DEFAULT_FONTS, + fontStack, + fontsEqual, + isFontRef, + type DocumentFonts, + type FontRef, +} from "../model/fonts"; + +const KEY = "margindocs-fonts"; + +/** + * How many documents are remembered, most recently set first. + * + * A cap because this is a map that only ever grows: every document somebody tries a face on stays + * in it forever otherwise, and localStorage is a few megabytes shared with everything else the app + * keeps. Two hundred is far past the number of documents anybody sets a font on by hand, and the + * entry that falls off the end is the one nobody has touched in the longest time. + */ +const LIMIT = 200; + +type Stored = Record; + +/** + * The map as it sits on disk, or an empty one. + * + * Guarded for the Node test environment and for a webview with storage denied, the same way + * src/store/useProofing.ts guards its two booleans. Anything that is not a pair of `FontRef`s is + * dropped rather than repaired: a preference written by a version that named its faces differently + * is not worth guessing at, and the default pair is always a correct answer. + */ +function read(): Stored { + if (typeof localStorage === "undefined") return {}; + try { + const raw = localStorage.getItem(KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return {}; + const out: Stored = {}; + for (const [path, value] of Object.entries(parsed as Record)) { + if (typeof value !== "object" || value === null) continue; + const { body, heading } = value as { body?: unknown; heading?: unknown }; + if (isFontRef(body) && isFontRef(heading)) out[path] = { body, heading }; + } + return out; + } catch { + return {}; + } +} + +function write(map: Stored): void { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(KEY, JSON.stringify(map)); + } catch { + // A webview with storage denied still changes the face, it just forgets between launches. + } +} + +/** + * Writes the two custom properties src/styles/prose.css sets the page from, and nothing else. + * + * The default pair removes them rather than restating the tokens.css values, so there is one place + * that decides what an unset document looks like. Same shape as src/width.ts and src/theme.ts: a + * setting is an attribute or a property on the root element, never a length or a family written + * into a component. + */ +function apply(fonts: DocumentFonts): void { + if (typeof document === "undefined") return; + const root = document.documentElement; + if (fontsEqual(fonts, DEFAULT_FONTS)) { + root.style.removeProperty("--font-book"); + root.style.removeProperty("--font-heading"); + return; + } + root.style.setProperty("--font-book", fontStack(fonts.body)); + root.style.setProperty("--font-heading", fontStack(fonts.heading)); +} + +interface DocumentFontsState { + /** What the open document is set in, or the default pair when nothing is open. */ + fonts: DocumentFonts; + /** Which file `fonts` is about, so a write cannot land on the document that has since replaced + * it. Null between a close and the next open. */ + path: string | null; + + /** Called when the open document changes, including to nothing. Applies what was remembered. */ + openFor: (path: string | null) => void; + setFonts: (fonts: DocumentFonts) => void; + setSlot: (slot: "body" | "heading", ref: FontRef) => void; + reset: () => void; +} + +export const useDocumentFonts = create((set, get) => ({ + fonts: DEFAULT_FONTS, + path: null, + + openFor: (path) => { + const fonts = path === null ? DEFAULT_FONTS : (read()[path] ?? DEFAULT_FONTS); + apply(fonts); + set({ fonts, path }); + }, + + // Applied before it is stored, because the face on screen is the thing the user asked for and + // storage is the part that is allowed to fail. + setFonts: (fonts) => { + const { path } = get(); + apply(fonts); + set({ fonts }); + if (path === null) return; + + const map = read(); + // Reinserted rather than assigned in place, so the key order is least-recently-set first and + // the trim below drops the right entries. A document set back to the default is removed + // instead of stored: an entry that says "what everything else already says" is only there to + // be evicted later. + delete map[path]; + if (!fontsEqual(fonts, DEFAULT_FONTS)) map[path] = fonts; + + const paths = Object.keys(map); + for (const stale of paths.slice(0, Math.max(0, paths.length - LIMIT))) delete map[stale]; + write(map); + }, + + setSlot: (slot, ref) => get().setFonts({ ...get().fonts, [slot]: ref }), + + reset: () => get().setFonts(DEFAULT_FONTS), +})); diff --git a/src/styles/app.css b/src/styles/app.css index cbce12c..a946177 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -144,9 +144,13 @@ select { color: var(--ink); } +/* On, or open. The two are the same picture on purpose: every one of these is a control whose + result is on this screen, so "this button is doing something right now" is the only distinction + worth drawing. The inset ring is what separates it from a hover, which shares the wash. */ .icon-button[data-active="true"] { background: var(--accent-wash); - color: var(--ink); + color: var(--accent); + box-shadow: inset 0 0 0 1px var(--line-strong); } :root[data-touch] .icon-button { diff --git a/src/styles/document-setup.css b/src/styles/document-setup.css new file mode 100644 index 0000000..dd4db5f --- /dev/null +++ b/src/styles/document-setup.css @@ -0,0 +1,142 @@ +/* The Document setup panel, opened by the filename in the title bar. + * + * It borrows .overlay, .panel, .panel-head, .panel-body and .panel-foot from app.css and the two + * buttons from tree.css, for the same reason src/styles/settings.css does: a dialog that looked + * like its own app would be the only thing on screen that did. What is here is the labelled field + * and the typography section, both ported from the sibling book app's Book setup so that the two + * panels are the same object rather than two designs of one idea. */ + +.panel-setup { + width: min(520px, calc(100vw - 32px)); +} + +/* A label over its control, which is the shape every field in this panel has. The settings panel + next door puts its label beside a switch instead, because a switch is an answer and these are + values. */ +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.field-label { + font-family: var(--font-ui); + font-size: var(--t-1); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-faint); +} + +.field input, +.field select { + font-family: var(--font-ui); + font-size: var(--t-4); + color: var(--ink); + background: var(--raised); + border: 1px solid var(--line-strong); + border-radius: var(--r-sm); + padding: 9px 11px; +} + +.field input:focus, +.field select:focus { + outline: none; + border-color: var(--accent); +} + +/* A select showing a font is set in that font, which is the point of the control. The rule above + would otherwise put the UI face on it and every option would read the same. */ +.field select { + font-family: inherit; +} + +.font-presets { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* A specimen with its name under it, rather than a row of five names. Pairing two faces is the part + of this that takes an eye, and five words set in one face say nothing about what any of them + looks like. */ +.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); + 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); +} + +/* The way down to the two selects, and a link rather than a button because what it does is reveal + rather than change. Nothing in the panel moves when it is pressed except what appears below it. */ +.font-advanced { + align-self: flex-start; + margin-top: 4px; + padding: 0; + font-family: var(--font-ui); + font-size: var(--t-2); + color: var(--accent); + 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); +} + +/* What the page will look like, since the page itself does not change until Save. A heading and a + line of prose, which is the smallest sample that shows both slots and the relation between + them. On --paper rather than --raised, because that is what a document sits on. */ +.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; +} + +/* The title bar's own button, which is now a way into this panel and says so while it is open. */ +.titlebar .doc-title[data-active="true"] { + color: var(--ink); +} diff --git a/src/styles/export-preview.css b/src/styles/export-preview.css new file mode 100644 index 0000000..b0da025 --- /dev/null +++ b/src/styles/export-preview.css @@ -0,0 +1,168 @@ +/* The PDF preview, drawn over the editor pane. + + Square and unshadowed, unlike every other panel in the app, because it is not floating over the + page: it is standing where the page was. The pane it covers runs to the window's edges on three + sides, so a rounded corner would show a sliver of the document underneath and read as a bug. The + scrim behind it still dims the sidebar, which is what says the app is waiting on this. + + The pages themselves are white on --shell rather than on --paper, in both themes. A PDF is white + paper, that is what the file will look like when it is opened anywhere else, and a preview that + inverted with the app's theme would be showing something that does not exist. */ + +.preview-overlay { + z-index: 30; +} + +.preview-panel { + display: flex; + flex-direction: column; + border: none; + border-radius: 0; + box-shadow: none; +} + +.preview-bar { + flex: none; + height: 52px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px; + background: var(--paper); + border-bottom: 1px solid var(--line); +} + +.preview-title { + flex: 1; + min-width: 0; + font-family: var(--font-book); + font-size: var(--t-4); + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-count { + font-family: var(--font-ui); + font-size: var(--t-2); + color: var(--ink-faint); + font-variant-numeric: tabular-nums; +} + +.preview-zoom { + display: flex; + align-items: center; + gap: 2px; +} + +/* A fixed width and tabular figures, so stepping through 75%, 100%, 125% does not shuffle the + buttons either side of it. */ +.preview-zoom span { + width: 44px; + text-align: center; + font-family: var(--font-ui); + font-size: var(--t-2); + color: var(--ink-soft); + font-variant-numeric: tabular-nums; +} + +.preview-bar .icon-button:disabled { + opacity: 0.4; + pointer-events: none; +} + +.preview-warn { + flex: none; + padding: 9px 18px; + background: var(--accent-wash); + color: var(--accent-ink); + font-family: var(--font-ui); + font-size: var(--t-2); + text-align: center; + border-bottom: 1px solid var(--line); +} + +.preview-stage { + flex: 1; + min-height: 0; + overflow: auto; + padding: 28px; + background: var(--shell); +} + +/* max-content so a page zoomed past the stage's width scrolls sideways instead of being squeezed, + and min-width 100% so one narrower than the stage still centres. */ +.preview-col { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + width: max-content; + min-width: 100%; + margin: 0 auto; +} + +.preview-page { + flex: none; + background: #fff; + border-radius: 2px; + box-shadow: var(--shadow-page); + overflow: hidden; +} + +.preview-canvas { + display: block; + width: 100%; + height: 100%; +} + +.preview-loading { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; +} + +.preview-loading p { + margin: 0; + font-family: var(--font-ui); + font-size: var(--t-3); + color: var(--ink-soft); +} + +.preview-spinner { + width: 30px; + height: 30px; + border: 3px solid var(--line-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: preview-spin 0.7s linear infinite; +} + +@keyframes preview-spin { + to { + transform: rotate(360deg); + } +} + +/* A compile that failed says why, in the compiler's own words and on its own lines. Wrapped rather + than scrolled sideways: a Typst error is a paragraph, not a line of code. */ +.preview-error { + margin: 0 auto; + max-width: 60ch; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); + font-size: var(--t-3); + line-height: 1.6; + color: var(--danger-ink); +} + +@media (prefers-reduced-motion: reduce) { + .preview-spinner { + animation-duration: 2.4s; + } +} diff --git a/src/styles/fonts.css b/src/styles/fonts.css index a7ff2d0..2f22bc5 100644 --- a/src/styles/fonts.css +++ b/src/styles/fonts.css @@ -1,31 +1,4 @@ -@font-face { - font-family: "Hanken Grotesk"; - src: url("/fonts/HankenGrotesk-VF.ttf") format("truetype-variations"); - font-weight: 100 900; - font-style: normal; - font-display: block; -} - -@font-face { - font-family: "Hanken Grotesk"; - src: url("/fonts/HankenGrotesk-Italic-VF.ttf") format("truetype-variations"); - font-weight: 100 900; - font-style: italic; - font-display: block; -} - -@font-face { - font-family: "Literata"; - src: url("/fonts/Literata-VF.ttf") format("truetype-variations"); - font-weight: 200 900; - font-style: normal; - font-display: block; -} - -@font-face { - font-family: "Literata"; - src: url("/fonts/Literata-Italic-VF.ttf") format("truetype-variations"); - font-weight: 200 900; - font-style: italic; - font-display: block; -} +/* The six families, declared once in margin-shared so that Margin and Margin Docs bind the same + files to the same family names. The faces themselves are served from this app's public/fonts, + which `pnpm fonts:sync` fills from the package and `pnpm fonts:check` verifies. */ +@import "margin-shared/css/fonts.css"; diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 1c38a4a..0b6a2ca 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -1,92 +1,23 @@ +/* The palette, the type scale and the shape constants come from margin-shared, which is the one + copy of the values both apps have to agree on. Everything below is what only a document editor + needs: a six level heading scale, the rules and code surfaces a page of prose draws, and the + measures the sheet is laid out at. */ +@import "margin-shared/css/tokens.css"; + +/* The handful of shape and scale tokens this app adds to the shared set. */ :root { - --font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; - --font-book: "Literata", Georgia, "Times New Roman", serif; - --font-heading: "Literata", Georgia, "Times New Roman", serif; --font-mono: ui-monospace, "SF Mono", Menlo, monospace; - - --r-sm: 5px; - --r-md: 8px; - --r-lg: 12px; --r-pill: 999px; - - --pane-sidebar: 248px; - --measure: 46em; - --titlebar-h: 46px; --traffic-pad: 84px; --touch-h: 44px; - - --t-1: 11px; - --t-2: 12px; - --t-3: 13px; - --t-4: 15px; --t-5: 16px; - - --ease: cubic-bezier(0.22, 0.61, 0.36, 1); -} - -:root, -:root[data-theme="light"] { - --paper: #fcfbf7; - --shell: #f1ece2; - --sidebar: #ece6da; - --raised: #fbfaf6; - - --ink: #23201b; - --ink-soft: #6b6458; - --ink-faint: #9b9484; - - --line: #e3ddce; - --line-strong: #d6cfbd; - - --accent: #2a2622; - --accent-ink: #100e0b; - --accent-wash: rgba(35, 32, 27, 0.08); - --accent-contrast: #faf7f0; - --selection: rgba(35, 32, 27, 0.14); - --glass: rgba(252, 251, 247, 0.86); - - --danger: #b4453a; - --danger-ink: #963327; - --danger-wash: rgba(180, 69, 58, 0.1); - --danger-contrast: #faf7f0; - - --shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09); - --shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14); - --shadow-raised: 0 1px 2px rgba(35, 32, 27, 0.05); - --scrim: rgba(35, 32, 27, 0.28); + --shadow-raised: 0 1px 2px rgba(35, 32, 27, 0.05); } :root[data-theme="dark"] { - --paper: #1d1a16; - --shell: #151310; - --sidebar: #1a1713; - --raised: #232019; - - --ink: #ece6da; - --ink-soft: #a89f8e; - --ink-faint: #756d5e; - - --line: #2c2823; - --line-strong: #3a352d; - - --accent: #efe9dc; - --accent-ink: #ffffff; - --accent-wash: rgba(239, 233, 220, 0.12); - --accent-contrast: #1a1714; - --selection: rgba(239, 233, 220, 0.18); - --glass: rgba(33, 30, 25, 0.88); - - --danger: #d97a6f; - --danger-ink: #e58e84; - --danger-wash: rgba(217, 122, 111, 0.16); - --danger-contrast: #1a1714; - - --shadow-page: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 34px rgba(0, 0, 0, 0.5); - --shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55); - --shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.35); - --scrim: rgba(0, 0, 0, 0.5); + --shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.35); } /* Document typography, added for the editor surface. The four step scale above sizes chrome and diff --git a/tests/export-writes-only-the-pdf.spec.ts b/tests/export-writes-only-the-pdf.spec.ts index 131fa1d..83e0988 100644 --- a/tests/export-writes-only-the-pdf.spec.ts +++ b/tests/export-writes-only-the-pdf.spec.ts @@ -22,12 +22,23 @@ // wrote nothing because it never got past the first paragraph is the shape of green this suite is // built to refuse. // -// WHAT A BROWSER CANNOT REACH. `exportPdf` gates on `isDesktop`, which is `isTauri` and not a +// The export is two gestures now rather than one. Cmd-E opens the preview in +// src/components/ExportPreview.tsx, which compiles and puts the pages on screen; Save PDF is what +// reaches the native panel and the write. That changes the shape of this test and not its subject: +// the promise is still that nothing between the keystroke and the write touches the document, and +// the command log below is still the whole of the evidence. If anything, the window in which a +// stray save could hide got longer, because the buffer now sits dirty behind an open panel for as +// long as the user looks at the pages. +// +// WHAT A BROWSER CANNOT REACH. The command gates on `isDesktop`, which is `isTauri` and not a // phone. `isTauri` reads `__TAURI_INTERNALS__` off the window and tests/disk.ts puts that there at -// document start, so the gate opens and the whole path runs: convert, draw, compile, panel, write. -// What is behind it is the dev fixture, so `pdf_compile` answers with a PDF header instead of a -// typeset document and `pdf_write` puts nothing anywhere. That the compiler produces a real PDF and -// that the write lands where it was pointed are Rust questions, and they are asked in +// document start, so the gate opens and the whole path runs: convert, draw, compile, preview, +// panel, write. What is behind it is the dev fixture, so `pdf_compile` answers with a PDF header +// instead of a typeset document and `pdf_write` puts nothing anywhere. Eight bytes is not a PDF +// pdf.js can open, so the preview's stage stays empty here and the page count stays at zero: what +// this suite drives is the bar around it, which is live either way because the bytes it saves came +// from the compiler and not from the reader. That the compiler produces a real PDF, that pdf.js +// draws it, and that the write lands where it was pointed are questions for a real build and for // src-tauri/tests/export_writes_only_the_pdf.rs. The one piece nothing can drive from either side // is the native save panel itself: it is an AppKit window, so this spec answers the command for it // and cannot say what a real panel would hand back for a name the user typed. That distinction @@ -200,6 +211,23 @@ async function runFromMenu(page: Page, command: string): Promise { expect(delivered, "the app has no menu-action listener to fire at").toBeGreaterThan(0); } +const preview = (page: Page) => page.locator(".preview-panel"); +const savePdf = (page: Page) => preview(page).locator(".btn-primary"); + +/** + * Opens the preview and waits for the compile behind it to finish. + * + * Save is disabled until there are bytes to save, so a live button is the signal that `pdf_compile` + * has answered. Waiting on that rather than on the pages is deliberate: the fixture's eight bytes + * never become pages, and a test that waited for a canvas would be waiting for the compiler this + * build does not have. + */ +async function openPreview(page: Page): Promise { + await runFromMenu(page, "export-pdf"); + await expect(preview(page)).toBeVisible(); + await expect(savePdf(page)).toBeEnabled({ timeout: EXPORTED }); +} + /** * Types a character into the first paragraph and holds the save that would follow, so the buffer is * dirty and the file is not, for as long as the caller needs. Answers with the log as it stood the @@ -242,8 +270,18 @@ test("an export of a document with unsaved edits writes nothing but the PDF", as window.__savePanel = target; }, TARGET); - await runFromMenu(page, "export-pdf"); + await openPreview(page); + + // The compile happened on the way into the preview and nothing has been written yet, which is + // the half of this flow that did not exist before. A panel that had reached for the save dialog + // on its own would show up here. + const compiled = (await sent(page)).slice(line).map((call) => call.command); + expect(compiled).toEqual(["plugin:event|listen", "pdf_compile", "plugin:event|unlisten"]); + + await savePdf(page).click(); await expect(toast(page)).toContainText("Exported README.pdf", { timeout: EXPORTED }); + // A written file is the end of the preview's job, so it closes behind the toast. + await expect(preview(page)).toHaveCount(0); const during = (await sent(page)).slice(line); @@ -320,9 +358,10 @@ test("cancelling the save panel leaves the document exactly where it was", async await expect(page.locator(".prose .mermaid-block")).toHaveCount(1); const line = await dirtyWithHeldSave(page); - // Left as null, which is what the panel answers when the user presses Cancel. + // `__savePanel` is left as null, which is what the panel answers when the user presses Cancel. - await runFromMenu(page, "export-pdf"); + await openPreview(page); + await savePdf(page).click(); await expect .poll(async () => (await commands(page)).filter((c) => c === "plugin:dialog|save").length, { timeout: EXPORTED, @@ -340,4 +379,10 @@ test("cancelling the save panel leaves the document exactly where it was", async expect(await disk(page)).toBe(SOURCE); await expect(page.locator(".dirty-dot")).toBeVisible(); await expect(toast(page)).toHaveCount(0); + + // And the preview is still up, holding the bytes it already compiled. Cancelling the save panel + // is somebody deciding not to write this file yet, not somebody finished looking at it, so a + // second Save must not cost a second compile. + await expect(preview(page)).toBeVisible(); + await expect(savePdf(page)).toBeEnabled(); });