// Package export turns a document's structure into the formats a // person can take away with them: Markdown today, PDF alongside it // (pdf.go), DOCX when §11.2's import work makes a round trip worth // having. // // The structure arrives as ProseMirror-shaped JSON from the Rust core // (core/src/fragment.rs's fragment_json, read through // internal/core's Handle.JSON) — not as the HTML the read view // renders, because "where does this list end" and "is this run bold" // are questions the markup answers only to a parser, and Go's standard // library has none. package export import ( "encoding/json" "fmt" ) // Node is one node of the document tree: an element with children, or // a text run with marks. It is the JSON the core emits, decoded — a // deliberately loose mirror rather than a validated schema, because // the core carries unknown nodes and unknown marks through on purpose // (a newer editor build must not lose text through an older // exporter), and a decoder that rejected them would undo that. type Node struct { Type string `json:"type"` Attrs Attrs `json:"attrs"` Content []Node `json:"content"` Text string `json:"text"` Marks []Mark `json:"marks"` } // Attrs is the small, closed set of node attributes the core emits. // // Indent is a level, not a measurement: the core emits 1–8 and both // renderers turn that into half an inch apiece. Keeping it a level here // rather than a width in points is what lets the screen and the PDF // disagree about the unit without disagreeing about the document — // views/show.html works in inches and this exporter in points, and // neither has to know the other's. type Attrs struct { Level int `json:"level"` Align string `json:"align"` Indent int `json:"indent"` } // Mark is one mark on a text run. Only the name matters — every mark // this build knows is attribute-free by design; see the MARKS table in // core/src/fragment.rs for why. type Mark struct { Type string `json:"type"` } // Has reports whether a run carries a named mark. func (n Node) Has(mark string) bool { for _, m := range n.Marks { if m.Type == mark { return true } } return false } // IsText reports whether this node is a text run rather than an // element. Checking the type rather than "does it have children" — // an empty paragraph has no children either, and is not text. func (n Node) IsText() bool { return n.Type == "text" } // Document is a whole document: its title (from the database row, not // from the CRDT — the title is not part of the content) and the root // node the core rendered. type Document struct { Title string Root Node } // Parse decodes the core's JSON into a Document. func Parse(title, structureJSON string) (Document, error) { var root Node if err := json.Unmarshal([]byte(structureJSON), &root); err != nil { return Document{}, fmt.Errorf("export: decode document structure: %w", err) } if root.Type != "doc" { return Document{}, fmt.Errorf("export: document structure has root type %q, want \"doc\"", root.Type) } return Document{Title: title, Root: root}, nil } // blockKind classifies a node for the writers below. Both Markdown and // PDF ask the same questions of the tree, and asking them in one place // is what keeps the two exports describing the same document. type blockKind int const ( blockUnknown blockKind = iota blockParagraph blockHeading blockBulletList blockOrderedList blockListItem blockQuote ) func kindOf(n Node) blockKind { switch n.Type { case "paragraph": return blockParagraph case "heading": return blockHeading case "bullet_list": return blockBulletList case "ordered_list": return blockOrderedList case "list_item": return blockListItem case "blockquote": return blockQuote } return blockUnknown } // headingLevel clamps to 1–6 a second time. The core already clamps // (fragment.rs), so this can only fire on a level the core did not // produce — but the two are separate binaries' worth of trust apart // once the JSON has crossed the ABI, and a "#######" in a Markdown // file is not a heading at all. // The two alignments that are not the default. core/src/fragment.rs's // ALIGNMENTS table is where this vocabulary is defined; these are the // same two values, spelled the same way, because they arrive from it // over the wire. const ( alignCentre = "center" alignRight = "right" ) // alignOf is the allowlist a second time, on this side of the ABI, and // it is exact — no case folding, no trimming, no synonyms. // // It exists for the same reason headingLevel clamps a level the core // has already clamped: by the time the JSON reaches this package it has // crossed a binary boundary, and this package's own decoder is // deliberately loose (Node carries unknown types and unknown marks // through, so a newer editor cannot lose a reader's text). Loose about // what it CARRIES, strict about what it ACTS on: an alignment this // build does not know lays out as left rather than as a guess, and // "CENTER" is not "center" — a normalisation here would be this // package quietly deciding a question the schema and the core have both // already answered, and would disagree with the read view, which drops // it. A document must not paginate one way on screen and another in its // own export. // // Empty means left, which is both "no attribute" and "align=left" — // the default spends nothing, exactly as it spends no class in the read // view. func alignOf(n Node) string { switch n.Attrs.Align { case alignCentre: return alignCentre case alignRight: return alignRight } return "" } func headingLevel(n Node) int { l := n.Attrs.Level if l < 1 { return 1 } if l > 6 { return 6 } return l }