oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1// Package export turns a document's structure into the formats a
2// person can take away with them: Markdown today, PDF alongside it
3// (pdf.go), DOCX when §11.2's import work makes a round trip worth
4// having.
5//
6// The structure arrives as ProseMirror-shaped JSON from the Rust core
7// (core/src/fragment.rs's fragment_json, read through
8// internal/core's Handle.JSON) — not as the HTML the read view
9// renders, because "where does this list end" and "is this run bold"
10// are questions the markup answers only to a parser, and Go's standard
11// library has none.
12package export
13
14import (
15 "encoding/json"
16 "fmt"
17)
18
19// Node is one node of the document tree: an element with children, or
20// a text run with marks. It is the JSON the core emits, decoded — a
21// deliberately loose mirror rather than a validated schema, because
22// the core carries unknown nodes and unknown marks through on purpose
23// (a newer editor build must not lose text through an older
24// exporter), and a decoder that rejected them would undo that.
25type Node struct {
26 Type string `json:"type"`
27 Attrs Attrs `json:"attrs"`
28 Content []Node `json:"content"`
29 Text string `json:"text"`
30 Marks []Mark `json:"marks"`
31}
32
33// Attrs is the small, closed set of node attributes the core emits.
34//
35// Indent is a level, not a measurement: the core emits 1–8 and both
36// renderers turn that into half an inch apiece. Keeping it a level here
37// rather than a width in points is what lets the screen and the PDF
38// disagree about the unit without disagreeing about the document —
39// views/show.html works in inches and this exporter in points, and
40// neither has to know the other's.
41type Attrs struct {
42 Level int `json:"level"`
43 Align string `json:"align"`
44 Indent int `json:"indent"`
45}
46
47// Mark is one mark on a text run. Only the name matters — every mark
48// this build knows is attribute-free by design; see the MARKS table in
49// core/src/fragment.rs for why.
50type Mark struct {
51 Type string `json:"type"`
52}
53
54// Has reports whether a run carries a named mark.
55func (n Node) Has(mark string) bool {
56 for _, m := range n.Marks {
57 if m.Type == mark {
58 return true
59 }
60 }
61 return false
62}
63
64// IsText reports whether this node is a text run rather than an
65// element. Checking the type rather than "does it have children" —
66// an empty paragraph has no children either, and is not text.
67func (n Node) IsText() bool { return n.Type == "text" }
68
69// Document is a whole document: its title (from the database row, not
70// from the CRDT — the title is not part of the content) and the root
71// node the core rendered.
72type Document struct {
73 Title string
74 Root Node
75}
76
77// Parse decodes the core's JSON into a Document.
78func Parse(title, structureJSON string) (Document, error) {
79 var root Node
80 if err := json.Unmarshal([]byte(structureJSON), &root); err != nil {
81 return Document{}, fmt.Errorf("export: decode document structure: %w", err)
82 }
83 if root.Type != "doc" {
84 return Document{}, fmt.Errorf("export: document structure has root type %q, want \"doc\"", root.Type)
85 }
86 return Document{Title: title, Root: root}, nil
87}
88
89// blockKind classifies a node for the writers below. Both Markdown and
90// PDF ask the same questions of the tree, and asking them in one place
91// is what keeps the two exports describing the same document.
92type blockKind int
93
94const (
95 blockUnknown blockKind = iota
96 blockParagraph
97 blockHeading
98 blockBulletList
99 blockOrderedList
100 blockListItem
101 blockQuote
102)
103
104func kindOf(n Node) blockKind {
105 switch n.Type {
106 case "paragraph":
107 return blockParagraph
108 case "heading":
109 return blockHeading
110 case "bullet_list":
111 return blockBulletList
112 case "ordered_list":
113 return blockOrderedList
114 case "list_item":
115 return blockListItem
116 case "blockquote":
117 return blockQuote
118 }
119 return blockUnknown
120}
121
122// headingLevel clamps to 1–6 a second time. The core already clamps
123// (fragment.rs), so this can only fire on a level the core did not
124// produce — but the two are separate binaries' worth of trust apart
125// once the JSON has crossed the ABI, and a "#######" in a Markdown
126// file is not a heading at all.
127// The two alignments that are not the default. core/src/fragment.rs's
128// ALIGNMENTS table is where this vocabulary is defined; these are the
129// same two values, spelled the same way, because they arrive from it
130// over the wire.
131const (
132 alignCentre = "center"
133 alignRight = "right"
134)
135
136// alignOf is the allowlist a second time, on this side of the ABI, and
137// it is exact — no case folding, no trimming, no synonyms.
138//
139// It exists for the same reason headingLevel clamps a level the core
140// has already clamped: by the time the JSON reaches this package it has
141// crossed a binary boundary, and this package's own decoder is
142// deliberately loose (Node carries unknown types and unknown marks
143// through, so a newer editor cannot lose a reader's text). Loose about
144// what it CARRIES, strict about what it ACTS on: an alignment this
145// build does not know lays out as left rather than as a guess, and
146// "CENTER" is not "center" — a normalisation here would be this
147// package quietly deciding a question the schema and the core have both
148// already answered, and would disagree with the read view, which drops
149// it. A document must not paginate one way on screen and another in its
150// own export.
151//
152// Empty means left, which is both "no attribute" and "align=left" —
153// the default spends nothing, exactly as it spends no class in the read
154// view.
155func alignOf(n Node) string {
156 switch n.Attrs.Align {
157 case alignCentre:
158 return alignCentre
159 case alignRight:
160 return alignRight
161 }
162 return ""
163}
164
165func headingLevel(n Node) int {
166 l := n.Attrs.Level
167 if l < 1 {
168 return 1
169 }
170 if l > 6 {
171 return 6
172 }
173 return l
174}
175