Skip to content

Commit

Permalink
feat(typst): support semantic link jump (#42)
Browse files Browse the repository at this point in the history
* feat: create semantic label for headings

* dev: add helper function to make heading ref

* feat: support semantic link jump

* docs: update supports secion

* docs: update todo list
  • Loading branch information
Myriad-Dreamin authored Dec 14, 2023
1 parent c3efa80 commit 4dedb52
Show file tree
Hide file tree
Showing 15 changed files with 274 additions and 31 deletions.
49 changes: 48 additions & 1 deletion cli/src/render/typst.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ use typst_ts_compiler::{
},
TypstSystemWorld,
};
use typst_ts_core::{config::CompileOpts, path::PathClean, TypstAbs, TypstDocument};
use typst_ts_core::{config::CompileOpts, path::PathClean, TakeAs, TypstAbs, TypstDocument};
use typst_ts_svg_exporter::flat_ir::{LayoutRegionNode, PageMetadata};

const THEME_LIST: [&str; 5] = ["light", "rust", "coal", "navy", "ayu"];

Expand Down Expand Up @@ -135,6 +136,52 @@ impl TypstRenderer {
for theme in THEME_LIST {
self.set_theme_target(theme);

// let path = path.clone().to_owned();
self.compiler_layer_mut()
.set_post_process_layout(move |_m, doc, layout| {
// println!("post process {}", path.display());

let LayoutRegionNode::Pages(pages) = layout else {
unreachable!();
};

let (mut meta, pages) = pages.take();

let introspector = &doc.introspector;
let labels = doc
.introspector
.all()
.flat_map(|elem| elem.label().zip(elem.location()))
.map(|(label, elem)| {
(
label.clone().as_str().to_owned(),
introspector.position(elem),
)
})
.map(|(label, pos)| {
(
label,
format!(
"p{}x{:.2}y{:.2}",
pos.page,
pos.point.x.to_pt(),
pos.point.y.to_pt()
),
)
})
.collect::<Vec<_>>();
// println!("{:#?}", labels);

let labels = serde_json::to_vec(&labels).unwrap_or_exit();

meta.push(PageMetadata::Custom(vec![(
"sema-label".into(),
labels.into(),
)]));

LayoutRegionNode::Pages(Arc::new((meta, pages)))
});

self.compiler
.compile(&mut self.fork_env::<true>())
.map_err(|_| error_once!("compile page theme", theme: theme))?;
Expand Down
43 changes: 33 additions & 10 deletions contrib/typst/gh-pages.typ
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// This is important for typst-book to produce a responsive layout
// and multiple targets.
#import "@preview/book:0.2.2": get-page-width, target, is-web-target, is-pdf-target
#import "@preview/book:0.2.2": get-page-width, target, is-web-target, is-pdf-target, plain-text

#let page-width = get-page-width()
#let is-pdf-target = is-pdf-target()
Expand Down Expand Up @@ -62,6 +62,17 @@
)
}

#let make-unique-label(it, disambiguator: 1) = label({
let k = plain-text(it).trim()
if disambiguator > 1 {
k + "_d" + str(disambiguator)
} else {
k
}
})

#let heading-reference(it, d: 1) = make-unique-label(it.body, disambiguator: d)

// The project function defines how your document looks.
// It takes your content and some metadata and formats it.
// Go ahead and customize it to your liking!
Expand Down Expand Up @@ -97,20 +108,32 @@
// set text style
set text(font: main-font, size: 16pt, fill: main-color, lang: "en")

let ld = state("label-disambiguator", (:))
let update-ld(k) = ld.update(it => {
it.insert(k, it.at(k, default: 0) + 1);
it
})
let get-ld(loc, k) = make-unique-label(k, disambiguator: ld.at(loc).at(k))

// render a dash to hint headings instead of bolding it.
show heading : set text(weight: "regular") if is-web-target
show heading : it => locate(loc => {
show heading : it => {
it
if is-web-target {
style(styles => {
let h = measure(it.body, styles).height;
place(left, dx: -20pt, dy: -h - 12pt, [
#set text(fill: dash-color)
#link(loc)[\#]
])
})
let title = plain-text(it.body).trim();
update-ld(title)
locate(loc => {
let dest = get-ld(loc, title);
style(styles => {
let h = measure(it.body, styles).height;
place(left, dx: -20pt, dy: -h - 12pt, [
#set text(fill: dash-color)
#link(loc)[\#] #dest
])
})
});
}
})
}

// link setting
show link : set text(fill: dash-color)
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { TypstRenderer } from '@myriaddreamin/typst.ts/dist/esm/renderer';

declare global {
interface Window {
typstGetRelatedElements: any;
handleTypstLocation: any;
getTypstTheme(): string;
typstRerender?: () => Promise<void>;
typstChangeTheme?: () => Promise<void>;
debounce<T extends { (...args: any[]): void }>(fn: T, delay = 200): T;
assignSemaHash: (u: number, x: number, y: number) => void;
typstBookRenderPage(
plugin: TypstSvgRenderer,
relPath: string,
Expand Down
127 changes: 113 additions & 14 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,40 @@ function postProcessCrossLinks(appElem: HTMLDivElement) {
appElem.querySelectorAll('.pseudo-link').forEach(link => {
// update target
const a = link.parentElement!;
if (origin && a.getAttribute('onclick') === null) {
let target = a.getAttribute('target');
if (target === '_blank') {
// remove the target attribute
a.removeAttribute('target');
if (origin) {
const onclick = a.getAttribute('onclick');
if (onclick === null) {
let target = a.getAttribute('target');
if (target === '_blank') {
// remove the target attribute
a.removeAttribute('target');
}
} else if (globalSemaLabels) {
if (onclick.startsWith('handleTypstLocation')) {
// get params(p, x, y) in 'handleTypstLocation(this, p, x, y)'
const [u, x, y] = onclick
.split('(')[1]
.split(')')[0]
.split(',')
.slice(1)
.map(s => Number.parseFloat(s.trim()));
for (const [label, pos] of globalSemaLabels) {
const [u1, x1, y1] = pos;
if (u === u1 && Math.abs(x - x1) < 0.01 && Math.abs(y - y1) < 0.01) {
// todo: deduplicate id
a.id = `typst-label-${label}`;
a.setAttribute('href', `#label-${label}`);
a.setAttribute('xlink:href', `#label-${label}`);
break;
}
}
}
}
}

// update cross-link
const href = a.getAttribute('href')! || a.getAttribute('xlink:href')!;
if (href.startsWith('cross-link')) {
const url = new URL(href);
const pathLabelUnicodes = url.searchParams.get('path-label')!;
const plb = pathLabelUnicodes
const decodeTypstUrlc = (s: string) =>
s
.split('-')
.map(s => {
const n = Number.parseInt(s);
Expand All @@ -47,16 +67,56 @@ function postProcessCrossLinks(appElem: HTMLDivElement) {
return String.fromCharCode(n);
}
})
.join('')
.replace('.typ', '.html');
const absolutePath = new URL(plb, window.location.href).href;
.join('');
const href = a.getAttribute('href')! || a.getAttribute('xlink:href')!;
if (href.startsWith('cross-link')) {
const url = new URL(href);
const pathLabelUnicodes = url.searchParams.get('path-label')!;
const labelUnicodes = url.searchParams.get('label');
const plb = decodeTypstUrlc(pathLabelUnicodes).replace('.typ', '.html');
let absolutePath = new URL(plb, window.location.href).href;
if (labelUnicodes) {
absolutePath += '#label-' + encodeURIComponent(decodeTypstUrlc(labelUnicodes));
}
a.setAttribute('href', absolutePath);
a.setAttribute('xlink:href', absolutePath);
// todo: label handling
}
});
}

let prevHovers: Element[] | undefined = undefined;

function updateHovers(elems: Element[]) {
if (prevHovers) {
for (const h of prevHovers) {
h.classList.remove('focus');
}
}
prevHovers = elems;
}
let globalSemaLabels: [string, [number, number, number]][] = [];

window.assignSemaHash = (u: number, x: number, y: number) => {
// console.log(`find labels ${u}:${x}:${y} in`, globalSemaLabels);
for (const [label, pos] of globalSemaLabels) {
const [u1, x1, y1] = pos;
if (u === u1 && Math.abs(x - x1) < 0.01 && Math.abs(y - y1) < 0.01) {
location.hash = `label-${label}`;
const semaLinkLocation = document.getElementById(`typst-label-${label}`);
const relatedElems: Element[] = window.typstGetRelatedElements(semaLinkLocation);
for (const h of relatedElems) {
h.classList.add('focus');
}
updateHovers(relatedElems);
return;
}
}
updateHovers([]);
// todo: multiple documents
location.hash = `loc-${u}x${x.toFixed(2)}x${y.toFixed(2)}`;
};

window.typstBookRenderPage = function (
plugin: TypstRenderer,
relPath: string,
Expand Down Expand Up @@ -100,18 +160,34 @@ window.typstBookRenderPage = function (
);
}

const dec = new TextDecoder();
reloadArtifact(currTheme).then(() => {
let initialRender = true;
const runRender = async () => {
// const t1 = performance.now();
// console.log('hold', svgModule, currTheme);

// todo: bad performance
appElem.style.margin = `0px`;

await plugin.renderToSvg({
const cached = await plugin.renderToSvg({
renderSession: svgModule!,
container: appElem,
});
if (!cached) {
const customs: [string, Uint8Array][] = await plugin.getCustomV1({
renderSession: svgModule!,
});
const semaLabel = customs.find(k => k[0] === 'sema-label');
if (semaLabel) {
const labelBin = semaLabel[1];
const labels = JSON.parse(dec.decode(labelBin));
globalSemaLabels = labels.map(([label, pos]: [string, string]) => {
const [_, u, x, y] = pos.split(/[pxy]/).map(Number.parseFloat);
return [encodeURIComponent(label), [u, x, y]];
});
}
}

postProcessCrossLinks(appElem);

Expand All @@ -138,6 +214,29 @@ window.typstBookRenderPage = function (
appElem.style.margin = `0 ${wMargin}px`;
}
}

if (!cached && window.location.hash) {
// console.log('hash', window.location.hash);

// parse location.hash = `loc-${page}x${x.toFixed(2)}x${y.toFixed(2)}`;
const hash = window.location.hash;
const firstSep = hash.indexOf('-');
// console.log('jump label', window.location.hash, firstSep, globalSemaLabels);
if (firstSep != -1 && hash.slice(0, firstSep) === '#label') {
const labelTarget = hash.slice(firstSep + 1);
for (const [label, pos] of globalSemaLabels) {
if (label === labelTarget) {
const [u, x, y] = pos;
// console.log('jump label', label, pos);
window.handleTypstLocation(appElem.firstElementChild!, u, x, y, {
behavior: initialRender ? 'smooth' : 'instant',
});
initialRender = false;
break;
}
}
}
}
};

let base = runRender();
Expand Down
10 changes: 8 additions & 2 deletions github-pages/docs/book.typ
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@
// - #chapter(none)[#text("= Introduction")]
- #chapter("format/build-meta.typ")[Build Metadata]
- #chapter("format/theme.typ")[Theme]
- #chapter(none)[Typst Support]
- #chapter("format/supports.typ")[Typst Support]
- #chapter("format/supports/cross-ref.typ")[Cross Reference]
- #chapter("format/supports/cross-ref-sample.typ")[Cross Reference Sample]
- #chapter("format/supports/embed-html.typ")[Embed Sanitized HTML Elements]
- #chapter("format/supports/multimedia.typ")[Multimedia components]
- #chapter("format/supports/sema-desc.typ")[Semantic Page Description]
- #chapter(none)[For developers]
- #chapter(none)[Typst-side APIs]
- #chapter(none)[typst-book CLI Internals]
Expand All @@ -49,6 +54,7 @@
#get-book-meta()

// re-export page template
#import "/contrib/typst/gh-pages.typ": project
#import "/contrib/typst/gh-pages.typ": project, heading-reference
#let book-page = project
#let cross-link = cross-link
#let heading-reference = heading-reference
12 changes: 12 additions & 0 deletions github-pages/docs/format/supports.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#import "/github-pages/docs/book.typ": book-page

#show: book-page.with(title: "Typst Supports")

In this section you will learn how to:

- Make a cross reference in the same page or to other pages.
- Embed HTML elements into the pages:
- `html-ext.iframe` corresponds to a ```html <iframe/>``` element
- Specifically, embed multimedia elements:
- `html-ext.video` corresponds to a ```html <video/>``` element
- `html-ext.audio` corresponds to a ```html <audio/>``` element
23 changes: 23 additions & 0 deletions github-pages/docs/format/supports/cross-ref-sample.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#import "/github-pages/docs/book.typ": book-page

#show: book-page.with(title: "Typst Supports - Cross Reference in other pages")

= Sample page for cross reference in other pages

#lorem(50)

== Subsection

#lorem(50)

== -sub option

#lorem(50)

== A sentence...

#lorem(50)

== Math equation $f = lambda x . x$ in heading

#lorem(50)
17 changes: 17 additions & 0 deletions github-pages/docs/format/supports/cross-ref.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#import "/github-pages/docs/book.typ": book-page, cross-link, heading-reference

#show: book-page.with(title: "Typst Supports - Cross Reference")

// begin of sample
#let p = "/format/supports/cross-ref-sample.typ"
- #cross-link(p)[cross reference to the sample page]
#let sub = heading-reference[== Subsection]
- #cross-link(p, reference: sub)[cross reference to ```typ == Subsection``` in the sample page]
#let ref-head = "== Math equation $f = lambda x . x$ in heading"
#let sub = heading-reference(eval(ref-head, mode: "markup"))
- #cross-link(p, reference: sub)[cross reference to #raw(lang: "typ", ref-head) in the sample page]
// end of sample

== List of Code

#raw(lang: "typ", read("cross-ref.typ").find(regex("// begin of sample[\s\S]*?// end of sample")).replace("\r", "").slice(18, -16).trim(), block: true)
Loading

0 comments on commit 4dedb52

Please sign in to comment.