diff --git a/cli/src/render/typst.rs b/cli/src/render/typst.rs index 87ab26f..5bc7be2 100644 --- a/cli/src/render/typst.rs +++ b/cli/src/render/typst.rs @@ -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"]; @@ -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::>(); + // 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::()) .map_err(|_| error_once!("compile page theme", theme: theme))?; diff --git a/contrib/typst/gh-pages.typ b/contrib/typst/gh-pages.typ index 5c3cfb2..9cf5752 100644 --- a/contrib/typst/gh-pages.typ +++ b/contrib/typst/gh-pages.typ @@ -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() @@ -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! @@ -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) diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts index aa28f87..2aa169b 100644 --- a/frontend/src/global.d.ts +++ b/frontend/src/global.d.ts @@ -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; typstChangeTheme?: () => Promise; debounce(fn: T, delay = 200): T; + assignSemaHash: (u: number, x: number, y: number) => void; typstBookRenderPage( plugin: TypstSvgRenderer, relPath: string, diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 2612fdb..bc1ef48 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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); @@ -47,9 +67,17 @@ 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 @@ -57,6 +85,38 @@ function postProcessCrossLinks(appElem: HTMLDivElement) { }); } +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, @@ -100,7 +160,9 @@ 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); @@ -108,10 +170,24 @@ window.typstBookRenderPage = function ( // 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); @@ -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(); diff --git a/github-pages/docs/book.typ b/github-pages/docs/book.typ index 7d48652..a643022 100644 --- a/github-pages/docs/book.typ +++ b/github-pages/docs/book.typ @@ -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] @@ -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 diff --git a/github-pages/docs/format/supports.typ b/github-pages/docs/format/supports.typ new file mode 100644 index 0000000..6564328 --- /dev/null +++ b/github-pages/docs/format/supports.typ @@ -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