Commit 7de7851fd9
Verified · cmc
GAPS.md +15 −7
| @@ -5,18 +5,16 @@ goldens come from orgo (validated against Emacs `ox-html`). The renderer is run | ||
| 5 | 5 | orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — |
| 6 | 6 | so that only real rendering differences remain. |
| 7 | 7 | |
| 8 | Of the 12 corpus cases, **5 match orgo exactly (`table`, `timestamps`, `footnote`, | |
| 9 | `minimal`, `headings`)** and 7 diverge. Each divergence below is a missing capability, | |
| 10 | recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`. When one | |
| 11 | is closed, its case flips to matching and the test fails until it is moved to `.matches` — | |
| 12 | that is how this list stays honest. | |
| 8 | Of the 12 corpus cases, **7 match orgo exactly (`table`, `timestamps`, `footnote`, | |
| 9 | `minimal`, `headings`, `core`, `images`)** and 5 diverge. Each divergence below is a missing | |
| 10 | capability, recorded in the `expectations` map in | |
| 11 | `Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to matching | |
| 12 | and the test fails until it is moved to `.matches` — that is how this list stays honest. | |
| 13 | 13 | |
| 14 | 14 | This is the backlog the shared package exists to work through. Roughly in value order: |
| 15 | 15 | |
| 16 | 16 | | Case | Missing capability | |
| 17 | 17 | |---|---| |
| 18 | | `images` | `[[file:…]]` image links are not recognized; `#+CAPTION:` figures are not built. | | |
| 19 | | `core` | Bare URLs in running text are not autolinked. | | |
| 20 | 18 | | `elements` | Unknown `#+KEYWORD:` lines (e.g. `#+FILETAGS:`) leak into the body as paragraph text. | |
| 21 | 19 | | `lists` | List nesting deeper than one level is flattened. | |
| 22 | 20 | | `tblfm` | `^` superscript is not rendered (e.g. `N^2` in a table cell). | |
| @@ -48,3 +46,13 @@ they are presentation policy, not parser capability: | ||
| 48 | 46 | title and rendered as `<span class="tag">`. TODO/DONE keywords and priority cookies are |
| 49 | 47 | still emitted as plain title text (orgo wraps them in styled spans); that is a separate, |
| 50 | 48 | cosmetic difference the skeleton does not distinguish. |
| 49 | **`core`** — bare `http(s)://` URLs in running text are autolinked (trailing sentence | |
| 50 | punctuation left outside the link), and task checkboxes render as `<code>[ ]</code>` / | |
| 51 | `<code>[X]</code>` / `<code>[-]</code>` the way org's HTML exporter emits them, rather | |
| 52 | than as `<input type="checkbox">`. | |
| 53 | **`images`** — `[[file:…]]` links have the `file:` prefix stripped and relative image | |
| 54 | `src`/link `href` are allowed (a scheme-less path cannot carry a `javascript:` payload). | |
| 55 | A standalone image line becomes `<p><img></p>`; an affiliated `#+CAPTION`/`#+ATTR_HTML` | |
| 56 | promotes it to a `<figure>`, with a numbered `<figcaption>` when a caption is present. | |
| 57 | `#+ATTR_HTML` `:key value` pairs (quoted values honored) become `<img>` attributes, and | |
| 58 | a described image link (`[[file:x.png][text]]`) renders as a link, not an inline image. | |
README.md +6 −6
| @@ -12,12 +12,12 @@ shared across apps. | ||
| 12 | 12 | Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with |
| 13 | 13 | nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:` |
| 14 | 14 | alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks, |
| 15 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links | |
| 16 | and linked images (`[[dest][label]]`), horizontal rules, comments, timestamps | |
| 17 | (`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes | |
| 18 | (references, inline, and definitions → a `<section class="footnotes">`), and | |
| 19 | inline markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`, | |
| 20 | `_underline_`, email autolinks). | |
| 15 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME` | |
| 16 | figures, org links and images (`[[file:x.png]]` → `<img>`, `[[dest][label]]` → | |
| 17 | link), horizontal rules, comments, timestamps (`<2024-01-15 Mon>`, inactive, | |
| 18 | times, and ranges → `<time>`), footnotes (references, inline, and definitions → | |
| 19 | a `<section class="footnotes">`), and inline markup (`*bold*`, `/italic/`, | |
| 20 | `~code~`, `=verbatim=`, `+strike+`, `_underline_`, bare-URL and email autolinks). | |
| 21 | 21 | |
| 22 | 22 | Output is sanitized: only `http`/`https`/`mailto` link schemes and |
| 23 | 23 | `http`/`https` image schemes are allowed; everything else is dropped. |
Sources/OrgSwift/Escaping.swift +12 −6
| @@ -43,14 +43,20 @@ private func sanitizeReadmeURLString( | ||
| 43 | 43 | return escapeHTMLAttribute(trimmedURL) |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | guard let components = URLComponents(string: trimmedURL), | |
| 47 | let scheme = components.scheme?.lowercased(), | |
| 48 | allowedSchemes.contains(scheme), | |
| 49 | let sanitizedURL = components.url?.absoluteString else { | |
| 50 | return nil | |
| 46 | if let scheme = URLComponents(string: trimmedURL)?.scheme?.lowercased() { | |
| 47 | guard allowedSchemes.contains(scheme), | |
| 48 | let sanitizedURL = URLComponents(string: trimmedURL)?.url?.absoluteString else { | |
| 49 | return nil | |
| 50 | } | |
| 51 | return escapeHTMLAttribute(sanitizedURL) | |
| 51 | 52 | } |
| 52 | 53 | |
| 53 | return escapeHTMLAttribute(sanitizedURL) | |
| 54 | // No scheme → a relative reference (`diagram.png`, `docs/x`). It cannot carry a | |
| 55 | // `javascript:`/`data:` payload, so it is safe to keep as-is; apps that supply a | |
| 56 | // resolver turn these into absolute URLs before they reach here. Reject only the | |
| 57 | // protocol-relative `//host` form, which points off-origin. | |
| 58 | guard !trimmedURL.hasPrefix("//") else { return nil } | |
| 59 | return escapeHTMLAttribute(trimmedURL) | |
| 54 | 60 | } |
| 55 | 61 | |
| 56 | 62 | // MARK: - Regex Helpers |
Sources/OrgSwift/Inline.swift +18
| @@ -75,6 +75,24 @@ func processOrgInline( | ||
| 75 | 75 | } |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | // Bare URLs in running text become links. Bracketed `[[…]]` links are already | |
| 79 | // protected above, so this only sees truly bare URLs; trailing sentence punctuation is | |
| 80 | // left outside the link. | |
| 81 | result = protectMatches( | |
| 82 | in: result, | |
| 83 | pattern: #"https?://[^\s<>()\[\]]+"#, | |
| 84 | protectedFragments: &protectedFragments | |
| 85 | ) { match, nsText in | |
| 86 | var url = nsText.substring(with: match.range) | |
| 87 | var trailing = "" | |
| 88 | while let last = url.last, ".,;:!?".contains(last) { | |
| 89 | trailing = String(last) + trailing | |
| 90 | url.removeLast() | |
| 91 | } | |
| 92 | guard let safe = sanitizedReadmeLinkURLString(url) else { return url + trailing } | |
| 93 | return #"<a href="\#(safe)">\#(url)</a>"# + trailing | |
| 94 | } | |
| 95 | ||
| 78 | 96 | result = protectMatches( |
| 79 | 97 | in: result, |
| 80 | 98 | pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, |
Sources/OrgSwift/Links.swift +93 −4
| @@ -63,12 +63,19 @@ private func parseOrgLink( | ||
| 63 | 63 | return nil |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | /// Strip org's `file:` link prefix. `[[file:diagram.png]]` targets a local path the same | |
| 67 | /// way `[[diagram.png]]` does; the scheme is org bookkeeping, not part of the URL. | |
| 68 | func normalizeOrgLinkTarget(_ target: String) -> String { | |
| 69 | target.hasPrefix("file:") ? String(target.dropFirst(5)) : target | |
| 70 | } | |
| 71 | ||
| 66 | 72 | private func renderOrgLink( |
| 67 | destination: String, | |
| 73 | destination rawDestination: String, | |
| 68 | 74 | label: String?, |
| 69 | 75 | imageURLResolver: ((String) -> String?)? = nil, |
| 70 | 76 | linkURLResolver: ((String) -> String?)? = nil |
| 71 | 77 | ) -> String { |
| 78 | let destination = normalizeOrgLinkTarget(rawDestination) | |
| 72 | 79 | if let label, label.hasPrefix("[["), label.hasSuffix("]]") { |
| 73 | 80 | let source = String(label.dropFirst(2).dropLast(2)) |
| 74 | 81 | if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) { |
| @@ -80,9 +87,12 @@ private func renderOrgLink( | ||
| 80 | 87 | } |
| 81 | 88 | } |
| 82 | 89 | |
| 83 | if let imageHTML = makeOrgImageHTML( | |
| 90 | // A bare `[[image]]` with no description is an inline image. `[[image][text]]` is a | |
| 91 | // link whose text happens to point at an image — org renders it as a link, not an | |
| 92 | // image, so only take the image path when there is no description. | |
| 93 | if label == nil, let imageHTML = makeOrgImageHTML( | |
| 84 | 94 | source: destination, |
| 85 | alt: label, | |
| 95 | alt: nil, | |
| 86 | 96 | imageURLResolver: imageURLResolver |
| 87 | 97 | ) { |
| 88 | 98 | return imageHTML |
| @@ -100,10 +110,11 @@ private func renderOrgLink( | ||
| 100 | 110 | } |
| 101 | 111 | |
| 102 | 112 | func makeOrgImageHTML( |
| 103 | source: String, | |
| 113 | source rawSource: String, | |
| 104 | 114 | alt: String?, |
| 105 | 115 | imageURLResolver: ((String) -> String?)? |
| 106 | 116 | ) -> String? { |
| 117 | let source = normalizeOrgLinkTarget(rawSource) | |
| 107 | 118 | guard isRenderableImageSource(source) else { return nil } |
| 108 | 119 | let resolvedSource = imageURLResolver?(source) ?? source |
| 109 | 120 | guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil } |
| @@ -117,6 +128,84 @@ private func isRenderableImageSource(_ source: String) -> Bool { | ||
| 117 | 128 | .contains(where: { lowercased.hasSuffix($0) }) |
| 118 | 129 | } |
| 119 | 130 | |
| 131 | /// If a whole line is just an image link with no description (`[[file:x.png]]`), return its | |
| 132 | /// path (with `file:` stripped). A link carrying a description is not a standalone image. | |
| 133 | func standaloneOrgImage(in line: String) -> String? { | |
| 134 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 135 | guard trimmed.hasPrefix("[[") && trimmed.hasSuffix("]]") else { return nil } | |
| 136 | let inner = String(trimmed.dropFirst(2).dropLast(2)) | |
| 137 | guard !inner.contains("][") else { return nil } | |
| 138 | let path = normalizeOrgLinkTarget(inner) | |
| 139 | guard isRenderableImageSource(path) else { return nil } | |
| 140 | return path | |
| 141 | } | |
| 142 | ||
| 143 | /// Build the `<img>` for a figure or bare image. `alt` comes from the caption (markup | |
| 144 | /// stripped) when present, else from an `:alt` in `#+ATTR_HTML`, else empty; the remaining | |
| 145 | /// `#+ATTR_HTML` pairs become attributes. | |
| 146 | func makeFigureImageHTML( | |
| 147 | path: String, | |
| 148 | caption: String?, | |
| 149 | attrHtml: String?, | |
| 150 | imageURLResolver: ((String) -> String?)? | |
| 151 | ) -> String { | |
| 152 | let resolved = imageURLResolver?(path) ?? path | |
| 153 | let src = sanitizedReadmeImageURLString(resolved) ?? escapeHTMLAttribute(path) | |
| 154 | let attrs = attrHtml.map(parseAttrHtml) ?? [] | |
| 155 | ||
| 156 | let alt: String | |
| 157 | if let caption { | |
| 158 | alt = stripOrgEmphasis(caption) | |
| 159 | } else if let attrAlt = attrs.first(where: { $0.key == "alt" })?.value { | |
| 160 | alt = attrAlt | |
| 161 | } else { | |
| 162 | alt = "" | |
| 163 | } | |
| 164 | ||
| 165 | var html = #"<img src="\#(src)" alt="\#(escapeHTMLAttribute(alt))""# | |
| 166 | for (key, value) in attrs where key != "alt" { | |
| 167 | html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\"" | |
| 168 | } | |
| 169 | html += ">" | |
| 170 | return html | |
| 171 | } | |
| 172 | ||
| 173 | /// Parse `#+ATTR_HTML` `:key value` pairs, honoring quoted values (`:alt "a cat, sitting"`). | |
| 174 | private func parseAttrHtml(_ value: String) -> [(key: String, value: String)] { | |
| 175 | guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else { | |
| 176 | return [] | |
| 177 | } | |
| 178 | let ns = value as NSString | |
| 179 | var result: [(String, String)] = [] | |
| 180 | for m in regex.matches(in: value, range: NSRange(location: 0, length: ns.length)) { | |
| 181 | let key = ns.substring(with: m.range(at: 1)).lowercased() | |
| 182 | var v = ns.substring(with: m.range(at: 2)) | |
| 183 | if v.count >= 2, v.hasPrefix("\""), v.hasSuffix("\"") { | |
| 184 | v = String(v.dropFirst().dropLast()) | |
| 185 | } | |
| 186 | result.append((key, v)) | |
| 187 | } | |
| 188 | return result | |
| 189 | } | |
| 190 | ||
| 191 | /// Strip paired org emphasis markers for plain-text uses like an image `alt`. | |
| 192 | private func stripOrgEmphasis(_ s: String) -> String { | |
| 193 | guard let regex = try? NSRegularExpression(pattern: #"(?<!\S)([/*_+=~])(.+?)\1(?=\s|$|[.,;:!?])"#) else { | |
| 194 | return s | |
| 195 | } | |
| 196 | var result = s | |
| 197 | for _ in 0..<3 { | |
| 198 | let ns = result as NSString | |
| 199 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: ns.length)) | |
| 200 | if matches.isEmpty { break } | |
| 201 | for m in matches.reversed() { | |
| 202 | let inner = ns.substring(with: m.range(at: 2)) | |
| 203 | result = (result as NSString).replacingCharacters(in: m.range, with: inner) | |
| 204 | } | |
| 205 | } | |
| 206 | return result | |
| 207 | } | |
| 208 | ||
| 120 | 209 | // MARK: - Relative link/image resolution |
| 121 | 210 | |
| 122 | 211 | func resolveRepositoryLinkURL( |
Sources/OrgSwift/Lists.swift +5 −2
| @@ -136,11 +136,14 @@ func renderTaskListItem( | ||
| 136 | 136 | let prefix = String(trimmed.prefix(4)) |
| 137 | 137 | let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces) |
| 138 | 138 | |
| 139 | // Rendered as org's HTML exporter does: the bracket state in a <code>, not an <input>. | |
| 139 | 140 | switch prefix { |
| 140 | 141 | case "[ ] ": |
| 141 | return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"# | |
| 142 | return #"<code>[ ]</code> \#(inlineRenderer(remainder))"# | |
| 142 | 143 | case "[x] ", "[X] ": |
| 143 | return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"# | |
| 144 | return #"<code>[X]</code> \#(inlineRenderer(remainder))"# | |
| 145 | case "[-] ": | |
| 146 | return #"<code>[-]</code> \#(inlineRenderer(remainder))"# | |
| 144 | 147 | default: |
| 145 | 148 | return inlineRenderer(text) |
| 146 | 149 | } |
Sources/OrgSwift/OrgRenderer.swift +38 −1
| @@ -174,8 +174,10 @@ func orgToHTML( | ||
| 174 | 174 | var verseLines: [String] = [] |
| 175 | 175 | var pendingBlockName: String? |
| 176 | 176 | var pendingBlockCaption: String? |
| 177 | var pendingAttrHtml: String? | |
| 177 | 178 | var activeBlockCaption: String? |
| 178 | 179 | var isWrappingBlockFigure = false |
| 180 | var figureNumber = 0 | |
| 179 | 181 | let footnotes = FootnoteCollector() |
| 180 | 182 | |
| 181 | 183 | func beginPendingBlockWrapperIfNeeded() { |
| @@ -382,6 +384,9 @@ func orgToHTML( | ||
| 382 | 384 | case "name": |
| 383 | 385 | pendingBlockName = directive.value |
| 384 | 386 | continue |
| 387 | case "attr_html": | |
| 388 | pendingAttrHtml = directive.value | |
| 389 | continue | |
| 385 | 390 | case "options", "property": |
| 386 | 391 | continue |
| 387 | 392 | default: |
| @@ -389,6 +394,37 @@ func orgToHTML( | ||
| 389 | 394 | } |
| 390 | 395 | } |
| 391 | 396 | |
| 397 | // A standalone image link on its own line. An affiliated #+CAPTION or #+ATTR_HTML | |
| 398 | // promotes it to a <figure>; otherwise it is a plain <p><img>. A link *with* a | |
| 399 | // description ([[file:x][label]]) is an ordinary link and falls through to the | |
| 400 | // paragraph path instead. | |
| 401 | if let imagePath = standaloneOrgImage(in: trimmed) { | |
| 402 | closeQuoteBlock() | |
| 403 | flushBlockState() | |
| 404 | let img = makeFigureImageHTML( | |
| 405 | path: imagePath, | |
| 406 | caption: pendingBlockCaption, | |
| 407 | attrHtml: pendingAttrHtml, | |
| 408 | imageURLResolver: imageURLResolver | |
| 409 | ) | |
| 410 | if pendingBlockCaption != nil || pendingAttrHtml != nil { | |
| 411 | html += "<figure>" + img | |
| 412 | if let caption = pendingBlockCaption { | |
| 413 | figureNumber += 1 | |
| 414 | html += #"<figcaption><span class="figure-number">Figure \#(figureNumber): </span>"# | |
| 415 | + processOrgInline(caption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 416 | + "</figcaption>" | |
| 417 | } | |
| 418 | html += "</figure>\n" | |
| 419 | } else { | |
| 420 | html += "<p>" + img + "</p>\n" | |
| 421 | } | |
| 422 | pendingBlockCaption = nil | |
| 423 | pendingBlockName = nil | |
| 424 | pendingAttrHtml = nil | |
| 425 | continue | |
| 426 | } | |
| 427 | ||
| 392 | 428 | if trimmed.lowercased().hasPrefix("#+begin_src") { |
| 393 | 429 | closeQuoteBlock() |
| 394 | 430 | flushBlockState() |
| @@ -550,9 +586,10 @@ func orgToHTML( | ||
| 550 | 586 | } |
| 551 | 587 | |
| 552 | 588 | // Regular text |
| 553 | if pendingBlockName != nil || pendingBlockCaption != nil { | |
| 589 | if pendingBlockName != nil || pendingBlockCaption != nil || pendingAttrHtml != nil { | |
| 554 | 590 | pendingBlockName = nil |
| 555 | 591 | pendingBlockCaption = nil |
| 592 | pendingAttrHtml = nil | |
| 556 | 593 | } |
| 557 | 594 | paragraph.append(line) |
| 558 | 595 | } |
Tests/OrgSwiftTests/ConformanceTests.swift +2 −2
| @@ -51,11 +51,11 @@ private enum Expectation { | ||
| 51 | 51 | private let expectations: [String: Expectation] = [ |
| 52 | 52 | "table": .matches, |
| 53 | 53 | "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"), |
| 54 | "core": .diverges("bare URLs are not autolinked"), | |
| 54 | "core": .matches, | |
| 55 | 55 | "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"), |
| 56 | 56 | "footnote": .matches, |
| 57 | 57 | "headings": .matches, |
| 58 | "images": .diverges("[[file:…]] image links and #+CAPTION figures are not supported"), | |
| 58 | "images": .matches, | |
| 59 | 59 | "lists": .diverges("list nesting deeper than one level is not represented"), |
| 60 | 60 | "minimal": .matches, |
| 61 | 61 | "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), |
Tests/OrgSwiftTests/OrgRendererTests.swift +73
| @@ -286,4 +286,77 @@ struct OrgRendererTests { | ||
| 286 | 286 | #expect(html.contains("<h1>Just a heading</h1>")) |
| 287 | 287 | #expect(!html.contains("class=\"tag\"")) |
| 288 | 288 | } |
| 289 | ||
| 290 | @Test | |
| 291 | func bareUrlBecomesLink() { | |
| 292 | let html = render("See https://example.com now.") | |
| 293 | #expect(html.contains(#"<a href="https://example.com">https://example.com</a>"#)) | |
| 294 | // Trailing sentence punctuation stays outside the link. | |
| 295 | let end = render("End at https://example.com.") | |
| 296 | #expect(end.contains(#"<a href="https://example.com">https://example.com</a>."#)) | |
| 297 | } | |
| 298 | ||
| 299 | @Test | |
| 300 | func taskCheckboxesRenderAsCode() { | |
| 301 | let html = render(""" | |
| 302 | - [ ] todo | |
| 303 | - [X] done | |
| 304 | - [-] partial | |
| 305 | """) | |
| 306 | #expect(html.contains("<code>[ ]</code> todo")) | |
| 307 | #expect(html.contains("<code>[X]</code> done")) | |
| 308 | #expect(html.contains("<code>[-]</code> partial")) | |
| 309 | #expect(!html.contains("<input")) | |
| 310 | } | |
| 311 | ||
| 312 | @Test | |
| 313 | func bareFileImageIsImgInParagraph() { | |
| 314 | let html = render("[[file:diagram.png]]") | |
| 315 | #expect(html.contains(#"<p><img src="diagram.png" alt=""></p>"#)) | |
| 316 | } | |
| 317 | ||
| 318 | @Test | |
| 319 | func describedFileImageIsALink() { | |
| 320 | // A description turns an image target into a link, not an inline image. | |
| 321 | let html = render("[[file:diagram.png][the diagram]]") | |
| 322 | #expect(html.contains(#"<a href="diagram.png">the diagram</a>"#)) | |
| 323 | #expect(!html.contains("<img")) | |
| 324 | } | |
| 325 | ||
| 326 | @Test | |
| 327 | func captionPromotesImageToFigure() { | |
| 328 | let html = render(""" | |
| 329 | #+CAPTION: A /stylised/ chart | |
| 330 | [[file:chart.png]] | |
| 331 | """) | |
| 332 | #expect(html.contains("<figure><img src=\"chart.png\"")) | |
| 333 | #expect(html.contains(#"<figcaption><span class="figure-number">Figure 1: </span>A <em>stylised</em> chart</figcaption>"#)) | |
| 334 | // alt is the caption with markup stripped. | |
| 335 | #expect(html.contains(#"alt="A stylised chart""#)) | |
| 336 | } | |
| 337 | ||
| 338 | @Test | |
| 339 | func attrHtmlPromotesImageToFigureWithoutCaption() { | |
| 340 | let html = render(""" | |
| 341 | #+ATTR_HTML: :alt "a cat, sitting" :loading lazy | |
| 342 | [[file:cat.jpg]] | |
| 343 | """) | |
| 344 | #expect(html.contains("<figure><img src=\"cat.jpg\"")) | |
| 345 | #expect(html.contains(#"alt="a cat, sitting""#)) | |
| 346 | #expect(html.contains(#"loading="lazy""#)) | |
| 347 | #expect(!html.contains("<figcaption")) | |
| 348 | } | |
| 349 | ||
| 350 | @Test | |
| 351 | func figuresNumberSequentially() { | |
| 352 | let html = render(""" | |
| 353 | #+CAPTION: First | |
| 354 | [[file:a.png]] | |
| 355 | ||
| 356 | #+CAPTION: Second | |
| 357 | [[file:b.png]] | |
| 358 | """) | |
| 359 | #expect(html.contains("Figure 1: </span>First")) | |
| 360 | #expect(html.contains("Figure 2: </span>Second")) | |
| 361 | } | |
| 289 | 362 | } |