Co-authored-by: paulaingate <49568567+paulaingate@users.noreply.github.com>
This commit is contained in:
Jack
2023-07-23 12:15:26 +01:00
committed by GitHub
parent 91011d3522
commit f01148cd8c
20 changed files with 1586 additions and 899 deletions

View File

@ -1,2 +0,0 @@
[build]
rustflags = ["--cfg=web_sys_unstable_apis"]

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
github: fenjalien

8
CHANGELOG.md Normal file
View File

@ -0,0 +1,8 @@
# 0.5.0
- Update Typst version to 0.6.0
- Attempt to fix bad canvas output
- Move compiler to a web worker
- Rework file reading
- Add support of reading packages outside of the vault
- Improve settings appearance (#7)
- Various bug fixes

627
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,21 @@
[package]
name = "obsidian-typst"
version = "0.4.2"
version = "0.5.0"
authors = ["fenjalien"]
edition = "2021"
description = "Renders `typst` code blocks to images with Typst."
readme = "README.md"
rust-version = "1.70"
[lib]
crate-type = ["cdylib"]
[dependencies]
# Everything to do with Typst
typst = { git = "https://github.com/typst/typst.git", tag = "v0.4.0" }
typst-library = { git = "https://github.com/typst/typst.git", tag = "v0.4.0" }
typst = { git = "https://github.com/typst/typst.git", tag = "v0.6.0" }
typst-library = { git = "https://github.com/typst/typst.git", tag = "v0.6.0" }
comemo = "0.3"
once_cell = "1.17.1"
siphasher = "0.3.10"
elsa = "1.8.0"
# Everything to do with wasm
@ -26,14 +24,14 @@ js-sys = "^0.3"
wasm-bindgen-futures = "^0.4"
serde = { version = "^1.0", features = ["derive"] }
serde-wasm-bindgen = "^0.5"
web-sys = { version = "^0.3", features = ["console", "Window", "FontData", "Blob", "ImageData"] }
web-sys = { version = "^0.3", features = [
"console",
"Window",
"FontData",
"Blob",
"ImageData",
] }
console_error_panic_hook = "0.1.7"
# [patch.crates-io]
# web-sys = { git = "https://github.com/fenjalien/wasm-bindgen.git" }
# js-sys = { git = "https://github.com/fenjalien/wasm-bindgen.git" }
# wasm-bindgen-futures = { git = "https://github.com/fenjalien/wasm-bindgen.git" }
# wasm-bindgen = { git = "https://github.com/fenjalien/wasm-bindgen.git" }
# [profile.release]
# debug = true
# Image handling
fast_image_resize = "2.7.3"

View File

@ -2,15 +2,20 @@
Renders `typst` code blocks, and optionally math blocks, into images using [Typst](https://github.com/typst/typst) through the power of WASM! This is still very much in development, so suggestions and bugs are welcome!
## Things to NOTE
## Small Things to NOTE
- This plugin uses Typst 0.6.0
- Typst does not currently support exporting to HTML only PDFs and PNGs. So due to image scaling, the rendered views may look a bit terrible. If you know how to fix this PLEASE HELP.
- File paths should be relative to the vault folder.
- System fonts are not loaded by default as this takes about 20 seconds (on my machine). There is an option in settings to enable them (requires a reload of the plugin).
- You can not import on mobile as file reading is NOT supported on mobile, this is due to `SharedArrayBuffer`s not being available on mobile but is available for some reason on desktop.
## Using Packages
The plugin supports only the reading of packages from the [`@preview`](https://github.com/typst/packages#downloads) and [`@local`](https://github.com/typst/packages#local-packages) namespaces. Please use the Typst cli to download your desired packages. This means the plugin accesses files outside of your vault but only to read them, it does not modify or create files outside of your vault.
## Math Block Usage
The plugin can render `typst` inside math blocks! By default this is off, to enable it set the "Override Math Blocks" setting or use the "Toggle Math Block Override" command. Math block types are conserved between Obsidian and Typst, `$...$` -> `$...$` and `$$...$$` -> `$ ... $`.
The plugin can render `typst` inside math blocks! By default this is off, to enable it set the "Override Math Blocks" setting or use the "Toggle math block override" command. Math block types are conserved between Obsidian and Typst, `$...$` -> `$...$` and `$$...$$` -> `$ ... $`.
From what I've experimented with, normal math blocks are okay with `typst` code but Typst is not happy with any Latex code.
From what I've experimented with, normal math blocks are okay with Typst but Typst is not happy with any Latex code.
For styling and using imports with math blocks see the next section.
@ -23,10 +28,8 @@ Preamables are prepended to your `typst` code before compiling. There are three
- `code`: Prepended to `typst` code only in code blocks.
## Known Issues
### "File Not Found" Error on First Load of Obsidian
When Obsidian first loads it sometimes tries to render before its files are resolved and cached.
To fix, simply select then deselect everything in the file, or close and re-open the file.
### Runtime Error Unreachable or Recursive Use Of Object
These occur when the Typst compiler panics for any reason and means the compiler cannot be used again until it is restarted. There should be more information in the console log so please create an issue with this error!
## Example
@ -61,14 +64,20 @@ The first #count numbers of the sequence are:
<img src="assets/example.png">
## Installation
Until this plugin is submitted to the community plugins please install it by copying `main.js`, `styles.css`, and `manifest.json` from the releases tab to the folder `.obsidian/plugins/obsidian-typst` in your vault.
Install "Typst Renderer" from the community plugins tab in settings
or
Install it by copying `main.js`, `styles.css`, and `manifest.json` from the releases tab to the folder `.obsidian/plugins/obsidian-typst` in your vault.
## TODO / Goals (In no particular order)
- [x] Better font loading
- [x] Fix importing
- [x] Fix Github Actions
- [ ] Better error handling
- [ ] Fix output image scaling
- [x]? Fix output image scaling
- [ ] Use HTML output
- [x] Override default equation rendering
- [ ] Custom editor for `.typ` files
- [ ] Mobile file reading
- [ ] Automate package downloading

68
compiler.worker.ts Normal file
View File

@ -0,0 +1,68 @@
//@ts-ignore
import wasmBin from './pkg/obsidian_typst_bg.wasm'
import * as typst from './pkg'
import { CompileCommand, WorkerRequest } from "types";
typst.initSync(wasmBin);
let canUseSharedArrayBuffer = new Boolean(false);
// let buffer: Int32Array;
let decoder = new TextDecoder()
function requestData(path: string): string {
if (!canUseSharedArrayBuffer) {
throw "Cannot read files on mobile"
}
// @ts-expect-error
let buffer = new Int32Array(new SharedArrayBuffer(4, { maxByteLength: 1e8 }))
buffer[0] = 0;
postMessage({ buffer, path })
const res = Atomics.wait(buffer, 0, 0);
if (buffer[0] == 0) {
return decoder.decode(Uint8Array.from(buffer.slice(1)))
}
throw buffer[0]
}
let compiler = new typst.SystemWorld("", requestData)
const compileAttempts = 5;
// function compile(data: CompileCommand) {
// for (let i = 1; i <= compileAttempts; i += 1) {
// try {
// return compiler.compile(data.source, data.path, data.pixel_per_pt, data.fill, data.size, data.display)
// } catch (error) {
// if (i < compileAttempts && ((error.name == "RuntimeError" && error.message == "unreachable") || (error.name == "Uncaught Error"))) {
// console.warn("Typst compiler crashed, attempting to restart: ", i);
// console.log(data);
// compiler.free()
// compiler = new typst.SystemWorld("", requestData)
// } else {
// console.log("name", error.name);
// console.log("message", error.message);
// throw error;
// }
// }
// }
// }
onmessage = (ev: MessageEvent<CompileCommand | true>) => {
if (ev.data == true) {
canUseSharedArrayBuffer = ev.data
} else if ("source" in ev.data) {
const data: CompileCommand = ev.data;
postMessage(compiler.compile(data.source, data.path, data.pixel_per_pt, data.fill, data.size, data.display))
// postMessage(compile(ev.data))
} else {
throw ev;
}
}
console.log("Typst compiler worker loaded!");

View File

@ -2,6 +2,8 @@ import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import inlineWorkerPlugin from "esbuild-plugin-inline-worker";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@ -69,13 +71,16 @@ const context = await esbuild.context({
treeShaking: true,
outfile: "main.js",
plugins: [
wasmPlugin
inlineWorkerPlugin({ format: "cjs", target: "es2018", plugins: [wasmPlugin] })
]
})
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
await context.rebuild();
process.exit(0)
// if (prod) {
// await context.rebuild();
// process.exit(0);
// } else {
// await context.watch();
// }

267
main.ts
View File

@ -1,11 +1,10 @@
import { App, HexString, Notice, Plugin, PluginSettingTab, Setting, Workspace, loadMathJax } from 'obsidian';
import { App, renderMath, HexString, Platform, Plugin, PluginSettingTab, Setting, loadMathJax, normalizePath } from 'obsidian';
// @ts-ignore
import typst_wasm_bin from './pkg/obsidian_typst_bg.wasm'
import typstInit, * as typst from './pkg/obsidian_typst'
import TypstCanvasElement from 'typst-canvas-element';
import Worker from "./compiler.worker.ts"
// temp.track()
import TypstCanvasElement from 'typst-canvas-element';
import { WorkerRequest } from 'types.js';
interface TypstPluginSettings {
noFill: boolean,
@ -27,85 +26,218 @@ const DEFAULT_SETTINGS: TypstPluginSettings = {
search_system: false,
override_math: false,
preamable: {
shared: "#let pxToPt = (p) => if p == auto {p} else {p * DPR * (72/96) * 1pt}\n#set text(fill: white, size: pxToPt(SIZE))",
math: "#set page(width: pxToPt(WIDTH), height: pxToPt(HEIGHT), margin: 0pt)\n#set align(horizon)",
code: "#set page(width: auto, height: auto, margin: 1em)"
shared: "#set text(fill: white, size: SIZE)\n#set page(width: WIDTH, height: HEIGHT)",
math: "#set page(margin: 0pt)\n#set align(horizon)",
code: "#set page(margin: (y: 1em, x: 0pt))"
}
}
export default class TypstPlugin extends Plugin {
settings: TypstPluginSettings;
compiler: typst.SystemWorld;
files: Map<string, string>;
compilerWorker: Worker;
tex2chtml: any;
resizeObserver: ResizeObserver;
prevCanvasHeight: number = 0;
textEncoder: TextEncoder
fs: any;
async onload() {
await typstInit(typst_wasm_bin)
this.compilerWorker = new Worker();
if (!Platform.isMobileApp) {
this.compilerWorker.postMessage(true);
this.fs = require("fs")
}
this.textEncoder = new TextEncoder()
await this.loadSettings()
let notice = new Notice("Loading fonts for Typst...");
this.compiler = await new typst.SystemWorld("", (path: string) => this.get_file(path), this.settings.search_system);
notice.hide();
notice = new Notice("Finished loading fonts for Typst", 5000);
this.registerEvent(
this.app.metadataCache.on("resolved", () => this.updateFileCache())
)
TypstCanvasElement.compile = (a, b, c, d) => this.typstToSizedImage(a, b, c, d)
customElements.define("typst-canvas", TypstCanvasElement, { extends: "canvas" })
TypstCanvasElement.compile = (a, b, c, d, e) => this.processThenCompileTypst(a, b, c, d, e)
if (customElements.get("typst-renderer") == undefined) {
customElements.define("typst-renderer", TypstCanvasElement, { extends: "canvas" })
}
await loadMathJax()
renderMath("", false);
// @ts-expect-error
this.tex2chtml = MathJax.tex2chtml
this.overrideMathJax(this.settings.override_math)
this.addCommand({
id: "math-override",
name: "Toggle Math Block Override",
id: "toggle-math-override",
name: "Toggle math block override",
callback: () => this.overrideMathJax(!this.settings.override_math)
})
this.addCommand({
id: "update-files",
name: "Update Cached .typ Files",
callback: () => this.updateFileCache()
})
this.addSettingTab(new TypstSettingTab(this.app, this));
this.registerMarkdownCodeBlockProcessor("typst", (source, el, ctx) => {
el.appendChild(this.typstToCanvas(`${this.settings.preamable.code}\n${source}`, true))
this.registerMarkdownCodeBlockProcessor("typst", async (source, el, ctx) => {
el.appendChild(this.createTypstCanvas("/" + ctx.sourcePath, `${this.settings.preamable.code}\n${source}`, true, false))
})
console.log("loaded Typst");
console.log("loaded Typst Renderer");
}
typstToImage(source: string) {
return this.compiler.compile(source, this.settings.pixel_per_pt, `${this.settings.fill}${this.settings.noFill ? "00" : "ff"}`)
// async loadCompilerWorker() {
// this.compilerWorker.
// }
async compileToTypst(path: string, source: string, size: number, display: boolean): Promise<ImageData> {
return await navigator.locks.request("typst renderer compiler", async (lock) => {
this.compilerWorker.postMessage({
source,
path,
pixel_per_pt: this.settings.pixel_per_pt,
fill: `${this.settings.fill}${this.settings.noFill ? "00" : "ff"}`,
size,
display
});
while (true) {
let result: ImageData | WorkerRequest = await new Promise((resolve, reject) => {
const listener = (ev: MessageEvent<ImageData>) => {
remove();
resolve(ev.data);
}
const errorListener = (error: ErrorEvent) => {
remove();
reject(error.message)
}
const remove = () => {
this.compilerWorker.removeEventListener("message", listener);
this.compilerWorker.removeEventListener("error", errorListener);
}
this.compilerWorker.addEventListener("message", listener);
this.compilerWorker.addEventListener("error", errorListener);
})
if (result instanceof ImageData) {
return result
}
// Cannot reach this point when in mobile app as the worker should
// not have a SharedArrayBuffer
await this.handleWorkerRequest(result)
}
})
}
typstToSizedImage(source: string, size: number, display: boolean, fontSize: number) {
const sizing = `#let (WIDTH, HEIGHT, SIZE, DPR) = (${display ? size : "auto"}, ${!display ? size : "auto"}, ${fontSize}, ${window.devicePixelRatio})`
return this.typstToImage(
`${sizing}\n${this.settings.preamable.shared}\n${source}`
async handleWorkerRequest({ buffer: wbuffer, path }: WorkerRequest) {
try {
let s = await (
path.startsWith("@")
? this.preparePackage(path)
: this.getFileString(path)
);
if (s) {
let buffer = Int32Array.from(this.textEncoder.encode(
s
));
if (wbuffer.byteLength < (buffer.byteLength + 4)) {
//@ts-expect-error
wbuffer.buffer.grow(buffer.byteLength + 4)
}
wbuffer.set(buffer, 1)
wbuffer[0] = 0
} else {
wbuffer[0] = -2
}
} catch (error) {
wbuffer[0] = -1
throw error
} finally {
Atomics.notify(wbuffer, 0)
}
}
async getFileString(path: string): Promise<string> {
if (require("path").isAbsolute(path)) {
return await this.fs.promises.readFile(path, { encoding: "utf8" })
} else {
return await this.app.vault.adapter.read(normalizePath(path))
}
}
async preparePackage(spec: string): Promise<string | undefined> {
spec = spec.slice(1)
let subdir = "/typst/packages/" + spec
let dir = normalizePath(this.getDataDir() + subdir)
if (this.fs.existsSync(dir)) {
return dir
}
dir = normalizePath(this.getCacheDir() + subdir)
if (this.fs.existsSync(dir)) {
return dir
}
}
getDataDir() {
if (Platform.isLinux) {
if ("XDG_DATA_HOME" in process.env) {
return process.env["XDG_DATA_HOME"]
} else {
return process.env["HOME"] + "/.local/share"
}
} else if (Platform.isWin) {
return process.env["APPDATA"]
} else if (Platform.isMacOS) {
return process.env["HOME"] + "/Library/Application Support"
}
throw "Cannot find data directory on an unknown platform"
}
getCacheDir() {
if (Platform.isLinux) {
if ("XDG_CACHE_HOME" in process.env) {
return process.env["XDG_DATA_HOME"]
} else {
return process.env["HOME"] + "/.cache"
}
} else if (Platform.isWin) {
return process.env["LOCALAPPDATA"]
} else if (Platform.isMacOS) {
return process.env["HOME"] + "/Library/Caches"
}
throw "Cannot find cache directory on an unknown platform"
}
async processThenCompileTypst(path: string, source: string, size: number, display: boolean, fontSize: number) {
const dpr = window.devicePixelRatio;
const pxToPt = (px: number) => (px * dpr * (72 / 96)).toString() + "pt"
const sizing = `#let (WIDTH, HEIGHT, SIZE) = (${display ? pxToPt(size) : "auto"}, ${!display ? pxToPt(size) : "auto"}, ${pxToPt(fontSize)})`
return this.compileToTypst(
path,
`${sizing}\n${this.settings.preamable.shared}\n${source}`,
size,
display
)
}
typstToCanvas(source: string, display: boolean) {
createTypstCanvas(path: string, source: string, display: boolean, math: boolean) {
let canvas = new TypstCanvasElement();
canvas.source = source
canvas.path = path
canvas.display = display
canvas.math = math
return canvas
}
typstMath2Html(source: string, r: { display: boolean }) {
createTypstMath(source: string, r: { display: boolean }) {
const display = r.display;
source = `${this.settings.preamable.math}\n${display ? `$ ${source} $` : `$${source}$`}`
return this.typstToCanvas(source, display)
return this.createTypstCanvas("/586f8912-f3a8-4455-8a4a-3729469c2cc1.typ", source, display, true)
}
onunload() {
//@ts-expect-error
// @ts-expect-error
MathJax.tex2chtml = this.tex2chtml
this.compilerWorker.terminate()
}
async overrideMathJax(value: boolean) {
@ -113,7 +245,7 @@ export default class TypstPlugin extends Plugin {
await this.saveSettings();
if (this.settings.override_math) {
// @ts-expect-error
MathJax.tex2chtml = (e, r) => this.typstMath2Html(e, r)
MathJax.tex2chtml = (e, r) => this.createTypstMath(e, r)
} else {
// @ts-expect-error
MathJax.tex2chtml = this.tex2chtml
@ -127,23 +259,6 @@ export default class TypstPlugin extends Plugin {
async saveSettings() {
await this.saveData(this.settings);
}
get_file(path: string) {
if (this.files.has(path)) {
return this.files.get(path)
}
console.error(`'${path}' is a folder or does not exist`, this.files.keys());
throw `'${path}' is a folder or does not exist`
}
async updateFileCache() {
this.files = new Map()
for (const file of this.app.vault.getFiles()) {
if (file.extension == "typ") {
this.files.set(file.path, await this.app.vault.cachedRead(file))
}
}
}
}
class TypstSettingTab extends PluginSettingTab {
@ -159,18 +274,7 @@ class TypstSettingTab extends PluginSettingTab {
containerEl.empty();
let fill_color = new Setting(containerEl)
.setName("Fill Color")
.setDisabled(this.plugin.settings.noFill)
.addColorPicker((picker) => {
picker.setValue(this.plugin.settings.fill)
.onChange(
async (value) => {
this.plugin.settings.fill = value;
await this.plugin.saveSettings();
}
)
})
new Setting(containerEl)
.setName("No Fill (Transparent)")
.addToggle((toggle) => {
@ -183,6 +287,20 @@ class TypstSettingTab extends PluginSettingTab {
}
)
});
let fill_color = new Setting(containerEl)
.setName("Fill Color")
.setDisabled(this.plugin.settings.noFill)
.addColorPicker((picker) => {
picker.setValue(this.plugin.settings.fill)
.onChange(
async (value) => {
this.plugin.settings.fill = value;
await this.plugin.saveSettings();
}
)
})
new Setting(containerEl)
.setName("Pixel Per Point")
.addSlider((slider) =>
@ -193,7 +311,10 @@ class TypstSettingTab extends PluginSettingTab {
this.plugin.settings.pixel_per_pt = value;
await this.plugin.saveSettings();
}
))
)
.setDynamicTooltip()
)
new Setting(containerEl)
.setName("Search System Fonts")
.setDesc(`Whether the plugin should search for system fonts.

View File

@ -1,7 +1,7 @@
{
"id": "typst",
"name": "Typst Renderer",
"version": "0.4.2",
"version": "0.5.0",
"minAppVersion": "1.0.0",
"description": "Renders `typst` code blocks and math blocks to images with Typst.",
"author": "fenjalien",

741
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,12 @@
{
"name": "obsidian-typst-plugin",
"version": "0.4.2",
"version": "0.5.0",
"description": "Renders `typst` code blocks to images with Typst.",
"main": "main.js",
"scripts": {
"wasm": "wasm-pack build --target web",
"dev": "wasm-pack build --target web --dev && node esbuild.config.mjs",
"dev": "node esbuild.config.mjs",
"wasm-dev": "wasm-pack build --target web --dev && node esbuild.config.mjs",
"wasm-build": "wasm-pack build --target web && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
@ -14,23 +15,17 @@
"author": "fenjalien",
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "^16.11.6",
"@types/temp": "^0.9.1",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"esbuild-plugin-wasm": "^1.0.0",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
"@types/node": "^20",
"@typescript-eslint/eslint-plugin": "^5",
"@typescript-eslint/parser": "^5",
"builtin-modules": "^3",
"esbuild": "^0.18",
"esbuild-plugin-inline-worker": "^0.1.1",
"typescript": "^5.1"
},
"dependencies": {
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.2.0",
"@codemirror/view": "^6.10.0",
"@lezer/common": "^1.0.2",
"obsidian": "latest",
"obsidian-typst": "file:pkg",
"temp": "^0.9.4"
"tslib": "^2"
}
}

49
src/fonts.rs Normal file
View File

@ -0,0 +1,49 @@
use typst::{
font::{Font, FontBook},
util::Bytes,
};
/// Searches for fonts.
pub struct FontSearcher {
/// Metadata about all discovered fonts.
pub book: FontBook,
/// Slots that the fonts are loaded into.
pub fonts: Vec<Font>,
}
impl FontSearcher {
pub fn new() -> Self {
Self {
book: FontBook::new(),
fonts: Vec::new(),
}
}
pub fn add_embedded(&mut self) {
let mut process = |bytes: &'static [u8]| {
let buffer = Bytes::from_static(bytes);
for font in Font::iter(buffer) {
self.book.push(font.info().clone());
self.fonts.push(font);
}
};
macro_rules! add {
($filename:literal) => {
process(include_bytes!(concat!("../assets/fonts/", $filename)));
};
}
// Embed default fonts.
add!("LinLibertine_R.ttf");
add!("LinLibertine_RB.ttf");
add!("LinLibertine_RBI.ttf");
add!("LinLibertine_RI.ttf");
add!("NewCMMath-Book.otf");
add!("NewCMMath-Regular.otf");
add!("DejaVuSansMono.ttf");
add!("DejaVuSansMono-Bold.ttf");
add!("DejaVuSansMono-Oblique.ttf");
add!("DejaVuSansMono-BoldOblique.ttf");
}
}

View File

@ -1,299 +1,279 @@
use comemo::Prehashed;
use elsa::FrozenVec;
use once_cell::unsync::OnceCell;
use siphasher::sip128::{Hasher128, SipHasher};
use fast_image_resize as fr;
use std::{
cell::{RefCell, RefMut},
cell::{OnceCell, RefCell, RefMut},
collections::HashMap,
hash::Hash,
num::NonZeroU32,
path::{Path, PathBuf},
str::FromStr,
};
use typst::{
diag::{FileError, FileResult},
eval::Library,
font::{Font, FontBook, FontInfo},
diag::{EcoString, FileError, FileResult, PackageError, PackageResult},
eval::{Datetime, Library},
file::{FileId, PackageSpec},
font::{Font, FontBook},
geom::{Color, RgbaColor},
syntax::{Source, SourceId},
util::{Buffer, PathExt},
syntax::Source,
util::{Bytes, PathExt},
World,
};
use wasm_bindgen::{prelude::*, Clamped};
use wasm_bindgen_futures::JsFuture;
use web_sys::{console, Blob, FontData, ImageData};
use web_sys::ImageData;
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
}
mod fonts;
mod paths;
#[wasm_bindgen(module = "fs")]
extern "C" {
#[wasm_bindgen(catch)]
fn readFileSync(path: &str) -> Result<JsValue, JsValue>;
}
use crate::fonts::FontSearcher;
use crate::paths::{PathHash, PathSlot};
/// A world that provides access to the operating system.
#[wasm_bindgen]
pub struct SystemWorld {
/// The root relative to which absolute paths are resolved.
root: PathBuf,
/// The input source.
main: FileId,
/// Typst's standard library.
library: Prehashed<Library>,
/// Metadata about discovered fonts.
book: Prehashed<FontBook>,
fonts: Vec<FontSlot>,
hashes: RefCell<HashMap<PathBuf, PathHash>>,
/// Storage of fonts
fonts: Vec<Font>,
/// Maps package-path combinations to canonical hashes. All package-path
/// combinations that point to thes same file are mapped to the same hash. To
/// be used in conjunction with `paths`.
hashes: RefCell<HashMap<FileId, FileResult<PathHash>>>,
/// Maps canonical path hashes to source files and buffers.
paths: RefCell<HashMap<PathHash, PathSlot>>,
sources: FrozenVec<Box<Source>>,
main: SourceId,
js_read_file: js_sys::Function,
/// The current date if requested. This is stored here to ensure it is
/// always the same within one compilation. Reset between compilations.
today: OnceCell<Option<Datetime>>,
packages: RefCell<HashMap<PackageSpec, PackageResult<PathBuf>>>,
resizer: fr::Resizer,
js_request_data: js_sys::Function,
}
#[wasm_bindgen]
impl SystemWorld {
#[wasm_bindgen(constructor)]
pub async fn new(
root: String,
js_read_file: &js_sys::Function,
search_system: bool,
) -> Result<SystemWorld, JsValue> {
pub fn new(root: String, js_read_file: &js_sys::Function) -> SystemWorld {
console_error_panic_hook::set_once();
let mut searcher = FontSearcher::new();
if search_system {
searcher.search_system().await?;
} else {
searcher.add_embedded();
}
Ok(Self {
Self {
root: PathBuf::from(root),
main: FileId::detached(),
library: Prehashed::new(typst_library::build()),
book: Prehashed::new(searcher.book),
fonts: searcher.fonts,
hashes: RefCell::default(),
paths: RefCell::default(),
sources: FrozenVec::new(),
main: SourceId::detached(),
js_read_file: js_read_file.clone(),
})
today: OnceCell::new(),
packages: RefCell::default(),
resizer: fr::Resizer::default(),
js_request_data: js_read_file.clone(),
}
}
fn reset(&mut self) {
self.hashes.borrow_mut().clear();
self.paths.borrow_mut().clear();
self.today.take();
}
pub fn compile(
&mut self,
source: String,
text: String,
path: String,
pixel_per_pt: f32,
fill: String,
size: u32,
display: bool,
) -> Result<ImageData, JsValue> {
self.sources.as_mut().clear();
self.hashes.borrow_mut().clear();
self.paths.borrow_mut().clear();
self.reset();
// Insert the main path slot
let system_path = PathBuf::from(path);
let hash = PathHash::new(&text);
self.main = FileId::new(None, &system_path);
self.hashes.borrow_mut().insert(self.main, Ok(hash));
self.paths.borrow_mut().insert(
hash,
PathSlot {
id: self.main,
system_path,
buffer: OnceCell::new(),
source: Ok(Source::new(self.main, text)),
},
);
self.main = self.insert("<user input>".as_ref(), source);
match typst::compile(self) {
Ok(document) => {
let render = typst::export::render(
let mut pixmap = typst::export::render(
&document.pages[0],
pixel_per_pt,
Color::Rgba(RgbaColor::from_str(&fill)?),
);
Ok(ImageData::new_with_u8_clamped_array_and_sh(
Clamped(render.data()),
render.width(),
render.height(),
)?)
let width = pixmap.width();
let height = pixmap.height();
// Create src image
let mut src_image = fr::Image::from_slice_u8(
NonZeroU32::new(width).unwrap(),
NonZeroU32::new(height).unwrap(),
pixmap.data_mut(),
fr::PixelType::U8x4,
)
.unwrap();
// Multiple RGB channels of source image by alpha channel
let alpha_mul_div = fr::MulDiv::default();
alpha_mul_div
.multiply_alpha_inplace(&mut src_image.view_mut())
.unwrap();
let dst_width = NonZeroU32::new(if display {
size
} else {
((size as f32 / height as f32) * width as f32) as u32
})
.unwrap_or(NonZeroU32::MIN);
let dst_height = NonZeroU32::new(if display {
((size as f32 / width as f32) * height as f32) as u32
} else {
size
})
.unwrap_or(NonZeroU32::MIN);
// Create container for data of destination image
let mut dst_image = fr::Image::new(dst_width, dst_height, src_image.pixel_type());
// Get mutable view of destination image data
let mut dst_view = dst_image.view_mut();
// Resize source image into buffer of destination image
self.resizer
.resize(&src_image.view(), &mut dst_view)
.unwrap();
alpha_mul_div.divide_alpha_inplace(&mut dst_view).unwrap();
ImageData::new_with_u8_clamped_array_and_sh(
Clamped(dst_image.buffer()),
dst_width.get(),
dst_height.get(),
)
}
Err(errors) => Err(format!("{:?}", *errors).into()),
Err(errors) => Err(format!(
"{:?}",
errors
.into_iter()
.map(|e| e.message)
.collect::<Vec<EcoString>>()
)
.into()),
}
}
}
impl World for SystemWorld {
fn root(&self) -> &Path {
&self.root
}
fn library(&self) -> &Prehashed<Library> {
&self.library
}
fn main(&self) -> &Source {
self.source(self.main)
}
fn resolve(&self, path: &Path) -> FileResult<SourceId> {
let path = self.root.join(path);
let path = path.as_path();
self.slot(path)?
.source
.get_or_init(|| {
let buf = self.read_file(path)?;
let text = String::from_utf8(buf)?;
Ok(self.insert(path, text))
})
.clone()
}
fn source(&self, id: SourceId) -> &Source {
&self.sources[id.into_u16() as usize]
}
fn book(&self) -> &Prehashed<FontBook> {
&self.book
}
fn font(&self, id: usize) -> Option<Font> {
let slot = &self.fonts[id];
slot.font
.get_or_init(|| Font::new(slot.buffer.clone(), slot.index))
.clone()
fn main(&self) -> Source {
self.source(self.main).unwrap()
}
fn file(&self, path: &Path) -> FileResult<Buffer> {
let path = self.root.join(path);
let path = path.as_path();
self.slot(path)?
.buffer
.get_or_init(|| self.read_file(path).map(Buffer::from))
.clone()
fn source(&self, id: FileId) -> FileResult<Source> {
self.slot(id)?.source()
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
self.slot(id)?.file()
}
fn font(&self, index: usize) -> Option<Font> {
Some(self.fonts[index].clone())
}
fn today(&self, _: Option<i64>) -> Option<Datetime> {
None
}
}
impl SystemWorld {
fn slot(&self, path: &Path) -> FileResult<RefMut<PathSlot>> {
let mut hashes = self.hashes.borrow_mut();
let hash = match hashes.get(path).cloned() {
Some(hash) => hash,
None => {
let hash = PathHash::new(Buffer::from(self.read_file(path)?));
if let Ok(canon) = path.canonicalize() {
hashes.insert(canon.normalize(), hash);
}
hashes.insert(path.into(), hash);
hash
}
};
Ok(std::cell::RefMut::map(self.paths.borrow_mut(), |paths| {
paths.entry(hash).or_default()
}))
}
fn insert(&self, path: &Path, text: String) -> SourceId {
let id = SourceId::from_u16(self.sources.len() as u16);
let source = Source::new(id, path, text);
self.sources.push(Box::new(source));
id
}
fn read_file(&self, path: &Path) -> FileResult<Vec<u8>> {
let f1 = |e: JsValue| {
console::error_1(&e);
FileError::Other
};
fn read_file(&self, path: &Path) -> FileResult<String> {
let f = |_e: JsValue| FileError::Other;
Ok(self
.js_read_file
.js_request_data
.call1(&JsValue::NULL, &path.to_str().unwrap().into())
.map_err(f1)?
.map_err(f)?
.as_string()
.unwrap())
}
fn prepare_package(&self, spec: &PackageSpec) -> PackageResult<PathBuf> {
let f = |e: JsValue| {
if let Some(num) = e.as_f64() {
if num == -2.0 {
return PackageError::NotFound(spec.clone());
}
}
PackageError::Other
};
self.packages
.borrow_mut()
.entry(spec.clone())
.or_insert_with(|| {
Ok(self
.js_request_data
.call1(
&JsValue::NULL,
&format!("@{}/{}-{}", spec.namespace, spec.name, spec.version).into(),
)
.map_err(f)?
.as_string()
.unwrap()
.into_bytes())
}
}
/// Holds details about the location of a font and lazily the font itself.
struct FontSlot {
buffer: Buffer,
index: u32,
font: OnceCell<Option<Font>>,
}
/// A hash that is the same for all paths pointing to the same entity.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
struct PathHash(u128);
impl PathHash {
fn new(handle: Buffer) -> Self {
// let handle = Buffer::from(read(path)?);
let mut state = SipHasher::new();
handle.hash(&mut state);
Self(state.finish128().as_u128())
}
}
/// Holds canonical data for all paths pointing to the same entity.
#[derive(Default)]
struct PathSlot {
source: OnceCell<FileResult<SourceId>>,
buffer: OnceCell<FileResult<Buffer>>,
}
struct FontSearcher {
book: FontBook,
fonts: Vec<FontSlot>,
}
impl FontSearcher {
fn new() -> Self {
Self {
book: FontBook::new(),
fonts: vec![],
}
.into())
})
.clone()
}
fn add_embedded(&mut self) {
let mut add = |bytes: &'static [u8]| {
let buffer = Buffer::from_static(bytes);
for (i, font) in Font::iter(buffer.clone()).enumerate() {
self.book.push(font.info().clone());
self.fonts.push(FontSlot {
buffer: buffer.clone(),
index: i as u32,
font: OnceCell::from(Some(font)),
});
}
fn slot(&self, id: FileId) -> FileResult<RefMut<PathSlot>> {
let mut system_path = PathBuf::new();
let mut text = String::new();
let hash = self
.hashes
.borrow_mut()
.entry(id)
.or_insert_with(|| {
let root = match id.package() {
Some(spec) => self.prepare_package(spec)?,
None => self.root.clone(),
};
// Embed default fonts.
add(include_bytes!("../assets/fonts/LinLibertine_R.ttf"));
add(include_bytes!("../assets/fonts/LinLibertine_RB.ttf"));
add(include_bytes!("../assets/fonts/LinLibertine_RBI.ttf"));
add(include_bytes!("../assets/fonts/LinLibertine_RI.ttf"));
add(include_bytes!("../assets/fonts/NewCMMath-Book.otf"));
add(include_bytes!("../assets/fonts/NewCMMath-Regular.otf"));
add(include_bytes!("../assets/fonts/DejaVuSansMono.ttf"));
add(include_bytes!("../assets/fonts/DejaVuSansMono-Bold.ttf"));
add(include_bytes!("../assets/fonts/DejaVuSansMono-Oblique.ttf"));
add(include_bytes!(
"../assets/fonts/DejaVuSansMono-BoldOblique.ttf"
));
}
system_path = root.join_rooted(id.path()).ok_or(FileError::AccessDenied)?;
text = self.read_file(&system_path)?;
async fn search_system(&mut self) -> Result<(), JsValue> {
if let Some(window) = web_sys::window() {
for fontdata in JsFuture::from(window.query_local_fonts()?)
.await?
.dyn_into::<js_sys::Array>()?
.to_vec()
{
let buffer = Buffer::from(
js_sys::Uint8Array::new(
&JsFuture::from(
JsFuture::from(fontdata.dyn_into::<FontData>()?.blob())
.await?
.dyn_into::<Blob>()?
.array_buffer(),
)
.await?,
)
.to_vec(),
);
for (i, info) in FontInfo::iter(buffer.as_slice()).enumerate() {
self.book.push(info);
self.fonts.push(FontSlot {
buffer: buffer.clone(),
index: i as u32,
font: OnceCell::new(),
Ok(PathHash::new(&text))
})
}
}
}
Ok(())
.clone()?;
Ok(RefMut::map(self.paths.borrow_mut(), |paths| {
paths.entry(hash).or_insert_with(|| PathSlot {
id,
source: Ok(Source::new(id, text)),
buffer: OnceCell::new(),
system_path,
})
}))
}
}

56
src/paths.rs Normal file
View File

@ -0,0 +1,56 @@
use std::{cell::OnceCell, hash::Hash, path::PathBuf};
use siphasher::sip128::{Hasher128, SipHasher13};
use typst::{diag::FileResult, file::FileId, syntax::Source, util::Bytes};
/// Holds canonical data for all pahts pointing to the same entity.
///
/// Both fields can be populated if the file is both imported and read().
pub struct PathSlot {
/// The slot's canonical file id.
pub id: FileId,
/// The slot's path on the system.
pub system_path: PathBuf,
/// The loaded buffer for a path hash.
pub buffer: OnceCell<FileResult<Bytes>>,
/// The lazily loaded source file for a path hash.
pub source: FileResult<Source>,
}
impl PathSlot {
pub fn source(&self) -> FileResult<Source> {
self.source.clone()
}
pub fn file(&self) -> FileResult<Bytes> {
self.buffer
.get_or_init(|| Ok(Bytes::from(self.source()?.text().as_bytes())))
.clone()
}
// pub fn source(&self) -> FileResult<Source> {
// self.source
// .get_or_init(|| {
// Ok(Source::new(
// self.id,
// String::from_utf8(self.buffer.clone()?.to_vec())?,
// ))
// })
// .clone()
// }
// pub fn file(&self) -> FileResult<Bytes> {
// self.buffer.clone()
// }
}
/// A hash that is the same for all paths pointing to the same entity.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct PathHash(u128);
impl PathHash {
pub fn new(source: &str) -> Self {
let mut state = SipHasher13::new();
source.hash(&mut state);
Self(state.finish128().as_u128())
}
}

View File

@ -1 +1,15 @@
/* styles */
textarea {
width: 100%;
resize: vertical;
}
.setting-item:has(textarea) {
flex-direction: column;
gap: 0.5em;
align-items: stretch;
}
.is-disabled {
display: none;
}

13
types.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
export interface CompileCommand {
source: string;
path: string;
pixel_per_pt: number;
fill: string;
size: number;
display: boolean;
}
export interface WorkerRequest {
buffer: Int32Array,
path: string
}

View File

@ -1,17 +1,31 @@
export default class TypstCanvasElement extends HTMLCanvasElement {
static compile: (source: string, size: number, display: boolean, fontSize: number) => ImageData;
static compile: (path: string, source: string, size: number, display: boolean, fontSize: number) => Promise<ImageData>;
static nextId = 0;
static prevHeight = 0;
id: string
abortController: AbortController
source: string
path: string
display: boolean
resizeObserver: ResizeObserver
size: number
math: boolean
connectedCallback() {
async connectedCallback() {
if (!this.isConnected) {
console.log("called before connection");
console.warn("Typst Renderer: Canvas element has been called before connection");
return;
}
this.draw()
// if (this.display && this.math) {
this.height = TypstCanvasElement.prevHeight;
// }
this.id = "TypstCanvasElement-" + TypstCanvasElement.nextId.toString()
TypstCanvasElement.nextId += 1
this.abortController = new AbortController()
if (this.display) {
this.resizeObserver = new ResizeObserver((entries) => {
if (entries[0]?.contentBoxSize[0].inlineSize !== this.size) {
@ -20,43 +34,58 @@ export default class TypstCanvasElement extends HTMLCanvasElement {
})
this.resizeObserver.observe(this.parentElement!.parentElement!)
}
await this.draw()
}
disconnectedCallback() {
if (this.display) {
TypstCanvasElement.prevHeight = this.height
if (this.display && this.resizeObserver != undefined) {
this.resizeObserver.disconnect()
}
}
draw() {
async draw() {
this.abortController.abort()
this.abortController = new AbortController()
try {
await navigator.locks.request(this.id, { signal: this.abortController.signal }, async () => {
let fontSize = parseFloat(this.getCssPropertyValue("--font-text-size"))
this.size = this.display ? this.parentElement!.parentElement!.innerWidth : parseFloat(this.getCssPropertyValue("--line-height-normal")) * fontSize
// console.log(size, this.parentElement);
if (this.display) {
this.style.width = "100%"
} else {
this.style.verticalAlign = "bottom"
this.style.height = `${this.size}px`
// resizeObserver can trigger before the element gets disconnected which can cause the size to be 0
// which causes a NaN. size can also sometimes be -ve so wait for resize to draw it again
if (this.size <= 0) {
return;
}
let image: ImageData;
let ctx = this.getContext("2d")!;
try {
image = TypstCanvasElement.compile(this.source, this.size, this.display, fontSize)
image =
await TypstCanvasElement.compile(this.path, this.source, this.size, this.display, fontSize)
} catch (error) {
console.error(error);
this.outerText = error
return
}
if (this.display) {
this.style.width = "100%"
this.style.height = ""
} else {
this.style.verticalAlign = "bottom"
this.style.height = `${this.size}px`
}
this.width = image.width
this.height = image.height
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = "high"
ctx.putImageData(image, 0, 0);
})
} catch (error) {
return
}
}
}

View File

@ -1,4 +1,5 @@
{
"0.5.0": "1.0.0",
"0.4.2": "1.0.0",
"0.4.1": "1.0.0",
"0.4.0": "1.0.0",