diff --git a/tests/input/sourcecode.xml b/tests/input/sourcecode.xml index ec77468b..df88edc4 100644 --- a/tests/input/sourcecode.xml +++ b/tests/input/sourcecode.xml @@ -127,6 +127,35 @@ print("49") print("50") +
+ RFC 8792 wrapping + Source code using the single backslash strategy: + + Text artwork using the double backslash strategy: + + Source code with renderer-added markers: + + A quoted header without folded content is not copyable: + +
diff --git a/tests/valid/sourcecode.pages.text b/tests/valid/sourcecode.pages.text index 0f9e2061..674374ea 100644 --- a/tests/valid/sourcecode.pages.text +++ b/tests/valid/sourcecode.pages.text @@ -3,9 +3,9 @@ Network Working Group H. Person, Ed. -Internet-Draft March 30, 2026 +Internet-Draft May 14, 2026 Intended status: Experimental -Expires: October 1, 2026 +Expires: November 15, 2026 xml2rfc sourcecode tests @@ -26,7 +26,7 @@ Status of This Memo time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress." - This Internet-Draft will expire on October 1, 2026. + This Internet-Draft will expire on November 15, 2026. Copyright Notice @@ -42,9 +42,10 @@ Copyright Notice Table of Contents 1. Less than 50 lines . . . . . . . . . . . . . . . . . . . . . 1 - 2. References . . . . . . . . . . . . . . . . . . . . . . . . . 4 + 2. RFC 8792 wrapping . . . . . . . . . . . . . . . . . . . . . . 4 + 3. References . . . . . . . . . . . . . . . . . . . . . . . . . 4 Index . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 - Author's Address . . . . . . . . . . . . . . . . . . . . . . . . 4 + Author's Address . . . . . . . . . . . . . . . . . . . . . . . . 5 1. Less than 50 lines @@ -52,10 +53,9 @@ Table of Contents - -Person Expires October 1, 2026 [Page 1] +Person Expires November 15, 2026 [Page 1] -Internet-Draft xml2rfc sourcecode tests March 2026 +Internet-Draft xml2rfc sourcecode tests May 2026 print("01") @@ -109,9 +109,9 @@ Internet-Draft xml2rfc sourcecode tests March 2026 -Person Expires October 1, 2026 [Page 2] +Person Expires November 15, 2026 [Page 2] -Internet-Draft xml2rfc sourcecode tests March 2026 +Internet-Draft xml2rfc sourcecode tests May 2026 print("49") @@ -165,9 +165,9 @@ Internet-Draft xml2rfc sourcecode tests March 2026 -Person Expires October 1, 2026 [Page 3] +Person Expires November 15, 2026 [Page 3] -Internet-Draft xml2rfc sourcecode tests March 2026 +Internet-Draft xml2rfc sourcecode tests May 2026 print("47") @@ -175,7 +175,41 @@ Internet-Draft xml2rfc sourcecode tests March 2026 print("49") print("50") -2. References +2. RFC 8792 wrapping + + Source code using the single backslash strategy: + + ========== NOTE: '\' line wrapping per RFC 8792 =========== + + first folded line \ + continues here + second folded line \ + also continues + + Text artwork using the double backslash strategy: + + [NOTE: '\\' line wrapping per RFC 8792] + + first folded line \ + \continues here + second folded line \ + \also continues + + Source code with renderer-added markers: + + file "marked-wrap.txt" + NOTE: '\' line wrapping per RFC 8792 + + marked folded line \ + continues here + + + A quoted header without folded content is not copyable: + + NOTE: '\' line wrapping per RFC 8792 + This block mentions the header but does not use RFC 8792 folding. + +3. References [ref0] Author, R. Q., "Reference". This is a reference not in a reference group. @@ -184,6 +218,14 @@ Index A + + + +Person Expires November 15, 2026 [Page 4] + +Internet-Draft xml2rfc sourcecode tests May 2026 + + A annotation Reference [ref0] @@ -221,4 +263,18 @@ Author's Address -Person Expires October 1, 2026 [Page 4] + + + + + + + + + + + + + + +Person Expires November 15, 2026 [Page 5] diff --git a/tests/valid/sourcecode.prepped.xml b/tests/valid/sourcecode.prepped.xml index 86e2eefc..d8e0ffd4 100644 --- a/tests/valid/sourcecode.prepped.xml +++ b/tests/valid/sourcecode.prepped.xml @@ -1,6 +1,6 @@ - - + + @@ -20,7 +20,7 @@ - +
Status of This Memo @@ -41,7 +41,7 @@ material or to cite them other than as "work in progress." - This Internet-Draft will expire on 1 October 2026. + This Internet-Draft will expire on 15 November 2026.
@@ -68,13 +68,16 @@ .  Less than 50 lines
  • - .  References + .  RFC 8792 wrapping
  • - Index + .  References
  • - Author's Address + Index +
  • +
  • + Author's Address
  • @@ -183,11 +186,40 @@ print("47") print("48") print("49") print("50") + + +
    + RFC 8792 wrapping + Source code using the single backslash strategy: + ========== NOTE: '\' line wrapping per RFC 8792 =========== + +first folded line \ +continues here +second folded line \ +also continues + + Text artwork using the double backslash strategy: + + Source code with renderer-added markers: + NOTE: '\' line wrapping per RFC 8792 + +marked folded line \ +continues here + + A quoted header without folded content is not copyable: + NOTE: '\' line wrapping per RFC 8792 +This block mentions the header but does not use RFC 8792 folding.
    - + References diff --git a/tests/valid/sourcecode.text b/tests/valid/sourcecode.text index 47be5a99..5c17c326 100644 --- a/tests/valid/sourcecode.text +++ b/tests/valid/sourcecode.text @@ -3,9 +3,9 @@ Network Working Group H. Person, Ed. -Internet-Draft March 30, 2026 +Internet-Draft May 14, 2026 Intended status: Experimental -Expires: October 1, 2026 +Expires: November 15, 2026 xml2rfc sourcecode tests @@ -26,7 +26,7 @@ Status of This Memo time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress." - This Internet-Draft will expire on October 1, 2026. + This Internet-Draft will expire on November 15, 2026. Copyright Notice @@ -42,7 +42,8 @@ Copyright Notice Table of Contents 1. Less than 50 lines - 2. References + 2. RFC 8792 wrapping + 3. References Index Author's Address @@ -149,7 +150,41 @@ Table of Contents print("49") print("50") -2. References +2. RFC 8792 wrapping + + Source code using the single backslash strategy: + + ========== NOTE: '\' line wrapping per RFC 8792 =========== + + first folded line \ + continues here + second folded line \ + also continues + + Text artwork using the double backslash strategy: + + [NOTE: '\\' line wrapping per RFC 8792] + + first folded line \ + \continues here + second folded line \ + \also continues + + Source code with renderer-added markers: + + file "marked-wrap.txt" + NOTE: '\' line wrapping per RFC 8792 + + marked folded line \ + continues here + + + A quoted header without folded content is not copyable: + + NOTE: '\' line wrapping per RFC 8792 + This block mentions the header but does not use RFC 8792 folding. + +3. References [ref0] Author, R. Q., "Reference". This is a reference not in a reference group. diff --git a/tests/valid/sourcecode.v3.html b/tests/valid/sourcecode.v3.html index 97959b92..00fd8cd1 100644 --- a/tests/valid/sourcecode.v3.html +++ b/tests/valid/sourcecode.v3.html @@ -6,7 +6,7 @@ xml2rfc sourcecode tests - + @@ -19,11 +19,11 @@ Internet-Draft xml2rfc sourcecode tests -March 2026 +May 2026 Person -Expires October 1, 2026 +Expires November 15, 2026 [Page] @@ -36,12 +36,12 @@
    sourcecode-00
    Published:
    - +
    Intended Status:
    Experimental
    Expires:
    -
    +
    Author:
    @@ -71,7 +71,7 @@

    time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."

    - This Internet-Draft will expire on October 1, 2026.

    + This Internet-Draft will expire on November 15, 2026.

    +

    +2. RFC 8792 wrapping +

    +

    Source code using the single backslash strategy:

    +
    +
    ========== NOTE: '\' line wrapping per RFC 8792 ===========
    +
    +first folded line \
    +continues here
    +second folded line \
    +also continues
    +
    + +
    +

    Text artwork using the double backslash strategy:

    +
    +
    [NOTE: '\\' line wrapping per RFC 8792]
    +
    +first folded line \
    +  \continues here
    +second folded line \
    +  \also continues
    +
    + +
    +

    Source code with renderer-added markers:

    +
    +
    <CODE BEGINS> file "marked-wrap.txt"
    +NOTE: '\' line wrapping per RFC 8792
    +
    +marked folded line \
    +continues here
    +
    +<CODE ENDS>
    + +
    +

    A quoted header without folded content is not copyable:

    +
    +
    NOTE: '\' line wrapping per RFC 8792
    +This block mentions the header but does not use RFC 8792 folding.
    +
    +
    +
    +

    -2. References +3. References

    [ref0]
    @@ -286,5 +333,124 @@

    toc.classList.remove("active"); }); + diff --git a/xml2rfc/data/xml2rfc-copy-unfolded.js b/xml2rfc/data/xml2rfc-copy-unfolded.js new file mode 100644 index 00000000..74441e6f --- /dev/null +++ b/xml2rfc/data/xml2rfc-copy-unfolded.js @@ -0,0 +1,118 @@ +function stripSourcecodeMarkers(text) { + const lines = text.split("\n"); + if (lines.length && /^(?: file "[^"]*")?$/.test(lines[0])) { + lines.shift(); + if (lines.length && /^ file "[^"]*"$/.test(lines[0])) { + lines.shift(); + } + } + if (lines.length && lines[lines.length - 1] === "") { + lines.pop(); + } + return lines.join("\n"); +} + +function findHeader(lines) { + const headers = [ + { strategy: "double", text: "NOTE: '\\\\' line wrapping per RFC 8792" }, + { strategy: "single", text: "NOTE: '\\' line wrapping per RFC 8792" }, + ]; + for (const header of headers) { + if (lines[0].includes(header.text)) { + return { strategy: header.strategy, index: 0 }; + } + if (lines.length > 1 && lines[0].startsWith("") && lines[1].includes(header.text)) { + return { strategy: header.strategy, index: 1 }; + } + if (lines.length > 2 && lines[0].startsWith("") && lines[1].startsWith(" file \"") && lines[2].includes(header.text)) { + return { strategy: header.strategy, index: 2 }; + } + } + return null; +} + +function unfoldText(text, stripMarkers) { + const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + const header = findHeader(lines); + + if (!header) { + return text; + } + + const before = lines.slice(0, header.index); + if (lines[header.index].startsWith("")) { + before.push(""); + } + const after = lines.slice(header.index + 1); + if (after.length && /^[ ]*$/.test(after[0])) { + after.shift(); + } + + const folded = after.join("\n"); + const unwrapped = header.strategy === "double" + ? folded.replace(/\\\n[ ]*\\/g, "") + : folded.replace(/\\\n[ ]*/g, ""); + + if (before.length) { + const result = `${before.join("\n")}\n${unwrapped}`; + return stripMarkers ? stripSourcecodeMarkers(result) : result; + } + return stripMarkers ? stripSourcecodeMarkers(unwrapped) : unwrapped; +} + +function copyTextWithTextarea(text) { + return new Promise((resolve, reject) => { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + textarea.style.left = "-1000px"; + document.body.appendChild(textarea); + textarea.select(); + + try { + if (document.execCommand("copy")) { + resolve(); + } else { + reject(new Error("copy command failed")); + } + } catch (error) { + reject(error); + } finally { + document.body.removeChild(textarea); + } + }); +} + +function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).catch(() => copyTextWithTextarea(text)); + } + return copyTextWithTextarea(text); +} + +document.querySelectorAll(".copy-unfolded").forEach(button => { + button.addEventListener("click", () => { + const block = button.closest(".has-copy-unfolded"); + const pre = block ? block.querySelector("pre") : null; + if (!block || !pre) { + return; + } + + const text = unfoldText(pre.textContent, block.dataset.sourcecodeMarkers === "true"); + copyText(text).then(() => { + button.textContent = "Copied"; + button.setAttribute("aria-label", "Copied"); + window.setTimeout(() => { + button.textContent = "Copy unfolded"; + button.setAttribute("aria-label", "Copy unfolded"); + }, 1600); + }).catch(() => { + button.textContent = "Error"; + window.setTimeout(() => { + button.textContent = "Copy unfolded"; + }, 1600); + }); + }); +}); diff --git a/xml2rfc/data/xml2rfc.css b/xml2rfc/data/xml2rfc.css index acc5d30a..c5c0493e 100644 --- a/xml2rfc/data/xml2rfc.css +++ b/xml2rfc/data/xml2rfc.css @@ -827,6 +827,42 @@ tt, code, pre { line-height: 1.12; } +/* Copy unfolded text from RFC 8792-style folded sourcecode/artwork blocks. */ +.has-copy-unfolded { + position: relative; +} +.copy-unfolded { + position: absolute; + top: 0.35em; + right: 0.35em; + z-index: 3; + opacity: 0; + pointer-events: none; + padding: 0.25em 0.5em; + border: 1px solid #777; + border-radius: 3px; + color: #fff; + background: #444; + font-family: var(--font-sans); + font-size: 0.85em; + line-height: 1.2; + cursor: pointer; +} +.copy-unfolded:hover, +.copy-unfolded:focus { + background: #222; +} +.has-copy-unfolded:hover > .copy-unfolded, +.has-copy-unfolded:focus-within > .copy-unfolded, +.copy-unfolded:focus { + opacity: 1; + pointer-events: auto; +} +@media print { + .copy-unfolded { + display: none; + } +} /* Add styling for a link in the ToC that points to the top of the document */ a.toplink { diff --git a/xml2rfc/writers/html.py b/xml2rfc/writers/html.py index 1edf26b0..a300e367 100644 --- a/xml2rfc/writers/html.py +++ b/xml2rfc/writers/html.py @@ -230,6 +230,7 @@ def __init__(self, xmlrfc, quiet=None, options=default_options, date=None): self.duplicate_html_ids = set() self.filename = None self.refname_mapping = self.get_refname_mapping() + self.has_copy_unfolded = False def html_tree(self): if not self.root.get('prepTime'): @@ -358,6 +359,43 @@ def contains_pilcrow_in_sub_element(self, e): def part_of_table_of_contents(self, e): return( len(e.xpath('ancestor::*[contains(@class, "toc")]')) > 0 ) + + def has_line_unfolding(self, text): + if not text: + return False + headers = [ + ('double', r"NOTE: '\\' line wrapping per RFC 8792"), + ('single', r"NOTE: '\' line wrapping per RFC 8792"), + ] + lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n') + for kind, header in headers: + if header in lines[0]: + body = lines[1:] + elif len(lines) > 1 and lines[0].startswith('') and header in lines[1]: + body = lines[2:] + elif len(lines) > 2 and lines[0].startswith('') and lines[1].startswith(' file "') and header in lines[2]: + body = lines[3:] + else: + continue + if kind == 'double': + for j, body_line in enumerate(body[:-1]): + if body_line.endswith('\\') and body[j + 1].lstrip(' ').startswith('\\'): + return True + elif any(body_line.endswith('\\') for body_line in body): + return True + return False + + def maybe_add_copy_unfolded_button(self, e, text, strip_sourcecode_markers=False): + if not self.has_line_unfolding(text): + return + classes = e.get('class', '') + e.set('class', ' '.join(s for s in [classes, 'has-copy-unfolded'] if s)) + if strip_sourcecode_markers: + e.set('data-sourcecode-markers', 'true') + button = build.button('Copy unfolded', type='button', classes='copy-unfolded') + button.set('aria-label', 'Copy unfolded') + e.append(button) + self.has_copy_unfolded = True def maybe_add_pilcrow(self, e, first=False): if not self.contains_pilcrow_in_sub_element(e) and not self.part_of_table_of_contents(e): @@ -662,6 +700,7 @@ def render_rfc(self, h, x): ) ) + self.has_copy_unfolded = False for c in [ e for e in [ x.find('front'), x.find('middle'), x.find('back') ] if e != None]: self.part = c.tag self.render(body, c) @@ -669,6 +708,10 @@ def render_rfc(self, h, x): with open(self.css_js, encoding='utf-8') as f: js = f.read() add.script(body, None, js) + if self.has_copy_unfolded: + with open(os.path.join(data_dir, 'xml2rfc-copy-unfolded.js'), encoding='utf-8') as f: + js = f.read() + add.script(body, None, js) return html @@ -847,6 +890,7 @@ def render_artwork(self, h, x): classes += ' art-%s' % type div = add.div(h, x, pre, classes=classes) div.text = None + self.maybe_add_copy_unfolded_button(div, pre.text) if x.getparent().tag != 'figure': self.maybe_add_pilcrow(div) return div @@ -2464,6 +2508,7 @@ def render_sourcecode(self, h, x): text = (' file "%s"\n%s' % (file, text)) if text else '\n%s' % text text = "%s\n" % text pre.text = text + self.maybe_add_copy_unfolded_button(div, pre.text, strip_sourcecode_markers=mark) if x.getparent().tag != 'figure': self.maybe_add_pilcrow(div) return div