mirror of
https://github.com/1j01/textual-paint.git
synced 2024-09-11 15:56:03 +03:00
Embed ANSI data in SVG for perfect round-trip
This commit is contained in:
parent
46017aca7b
commit
46e7e9bb30
@ -18,10 +18,7 @@ This is a TUI (Text User Interface) image editor, inspired by MS Paint, built wi
|
|||||||
- File formats, chosen by typing a file extension in the Save As dialog:
|
- File formats, chosen by typing a file extension in the Save As dialog:
|
||||||
- [x] ANSI (.ans) — Note that while it handles many more ANSI control codes when loading than those that it uses to save files, you may have limited success loading other ANSI files that you find on the web, or create with other tools. ANSI files can vary a lot and even encode animations!
|
- [x] ANSI (.ans) — Note that while it handles many more ANSI control codes when loading than those that it uses to save files, you may have limited success loading other ANSI files that you find on the web, or create with other tools. ANSI files can vary a lot and even encode animations!
|
||||||
- [x] Plain Text (.txt) — discards color information
|
- [x] Plain Text (.txt) — discards color information
|
||||||
- [x] SVG (.svg) — mainly save only; can open SVGs saved by Textual Paint, but it's not a perfect round-trip
|
- [x] SVG (.svg) — can open SVGs saved by Textual Paint, which embed ANSI data; can also open some other SVGs that consist of a grid of rectangles and text elements. For fun, as a challenge, I made it quite flexible; it can handle uneven grids of unsorted rectangles. But that's only used as a fallback, because it's not perfect.
|
||||||
- wide characters are treated as one cell wide when opening, leading to shifting of cells to the right
|
|
||||||
- beware opening large documents saved as SVG, as it gets exponentially slower with size
|
|
||||||
- note that for fun, as a challenge, I made it quite flexible; it can handle uneven grids of unsorted rectangles (but probably to fix the performance, I'll make it more strict)
|
|
||||||
- [x] HTML (.html) — save only
|
- [x] HTML (.html) — save only
|
||||||
- [x] PNG (.png)
|
- [x] PNG (.png)
|
||||||
- [x] Bitmap (.bmp)
|
- [x] Bitmap (.bmp)
|
||||||
|
@ -100,6 +100,7 @@
|
|||||||
"truecolor",
|
"truecolor",
|
||||||
"tspan",
|
"tspan",
|
||||||
"tspans",
|
"tspans",
|
||||||
|
"txtpnt",
|
||||||
"ufeff",
|
"ufeff",
|
||||||
"undoable",
|
"undoable",
|
||||||
"undos",
|
"undos",
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import html
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -574,11 +576,19 @@ assert ansi_detector_pattern.search("\x80") is None, "Ç (in CP 437) or € (U+0
|
|||||||
|
|
||||||
# This SVG template is based on the template in rich/_export_format.py
|
# This SVG template is based on the template in rich/_export_format.py
|
||||||
# It removes the simulated window frame, and crops the SVG to just the terminal content.
|
# It removes the simulated window frame, and crops the SVG to just the terminal content.
|
||||||
|
# It also adds a placeholder for ANSI data to be stored in the SVG,
|
||||||
|
# in order to support opening the file after saving it, in a perfectly lossless way.
|
||||||
|
# (I have also implemented a more general SVG loading mechanism, but it's now a fallback.)
|
||||||
# It was very nice during development to automate saving a file as SVG:
|
# It was very nice during development to automate saving a file as SVG:
|
||||||
# textual run --dev "src.textual_paint.paint --restart-on-changes samples/ship.ans" --press ctrl+shift+s,.,s,v,g,enter
|
# textual run --dev "src.textual_paint.paint --restart-on-changes samples/ship.ans" --press ctrl+shift+s,.,s,v,g,enter
|
||||||
# (The Ctrl+Shift+S shortcut doesn't work when actually trying it as a user, but it works to simulate it.)
|
# (The Ctrl+Shift+S shortcut doesn't work when actually trying it as a user, but it works to simulate it.)
|
||||||
CUSTOM_CONSOLE_SVG_FORMAT = """\
|
CUSTOM_CONSOLE_SVG_FORMAT = """\
|
||||||
<svg class="rich-terminal" viewBox="0 0 {terminal_width} {terminal_height}" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
|
class="rich-terminal"
|
||||||
|
viewBox="0 0 {terminal_width} {terminal_height}"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:txtpnt="http://github.com/1j01/textual-paint"
|
||||||
|
>
|
||||||
<!-- Generated with Rich https://www.textualize.io and Textual Paint https://github.com/1j01/textual-paint -->
|
<!-- Generated with Rich https://www.textualize.io and Textual Paint https://github.com/1j01/textual-paint -->
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
@ -614,6 +624,7 @@ CUSTOM_CONSOLE_SVG_FORMAT = """\
|
|||||||
<rect x="0" y="0" width="{terminal_width}" height="{terminal_height}" />
|
<rect x="0" y="0" width="{terminal_width}" height="{terminal_height}" />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
{lines}
|
{lines}
|
||||||
|
<txtpnt:ansi>%ANSI_GOES_HERE%</txtpnt:ansi>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<g clip-path="url(#{unique_id}-clip-terminal)">
|
<g clip-path="url(#{unique_id}-clip-terminal)">
|
||||||
@ -834,7 +845,13 @@ class AnsiArtDocument:
|
|||||||
def get_svg(self) -> str:
|
def get_svg(self) -> str:
|
||||||
"""Get the SVG representation of the document."""
|
"""Get the SVG representation of the document."""
|
||||||
console = self.get_console()
|
console = self.get_console()
|
||||||
return console.export_svg(code_format=CUSTOM_CONSOLE_SVG_FORMAT)
|
svg = console.export_svg(code_format=CUSTOM_CONSOLE_SVG_FORMAT)
|
||||||
|
# Include ANSI in the SVG so it can be loaded perfectly when re-opened.
|
||||||
|
# This can't use the .format() template since e.g. "{anything}" in the document would case a KeyError.
|
||||||
|
# (And I can't do it beforehand on the template because the template uses .format() itself...
|
||||||
|
# unless I escaped all the braces, but that would be ugly! So I'm just using a different syntax.)
|
||||||
|
# `html.escape` leaves control codes, which blows up ET.fromstring, so use base64 instead.
|
||||||
|
return svg.replace("%ANSI_GOES_HERE%", base64.b64encode(self.get_ansi().encode("utf-8")).decode("utf-8"))
|
||||||
|
|
||||||
def get_renderable(self) -> Text:
|
def get_renderable(self) -> Text:
|
||||||
"""Get a Rich renderable for the document."""
|
"""Get a Rich renderable for the document."""
|
||||||
@ -1072,6 +1089,8 @@ class AnsiArtDocument:
|
|||||||
def from_svg(svg: str, default_bg: str = "#ffffff", default_fg: str = "#000000") -> 'AnsiArtDocument':
|
def from_svg(svg: str, default_bg: str = "#ffffff", default_fg: str = "#000000") -> 'AnsiArtDocument':
|
||||||
"""Creates a document from an SVG containing a character grid with rects for cell backgrounds.
|
"""Creates a document from an SVG containing a character grid with rects for cell backgrounds.
|
||||||
|
|
||||||
|
- If the SVG contains a special <ansi> element, this is used instead of anything else.
|
||||||
|
Otherwise it falls back to flexible grid detection:
|
||||||
- rect elements can be in any order.
|
- rect elements can be in any order.
|
||||||
- rect elements can even be in different groups, however transforms are not considered.
|
- rect elements can even be in different groups, however transforms are not considered.
|
||||||
- rect elements can vary in size, spanning different numbers of cells, and the grid can be wonky.
|
- rect elements can vary in size, spanning different numbers of cells, and the grid can be wonky.
|
||||||
@ -1100,6 +1119,13 @@ class AnsiArtDocument:
|
|||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
root = ET.fromstring(svg)
|
root = ET.fromstring(svg)
|
||||||
|
|
||||||
|
ansi_el = root.find(".//{http://github.com/1j01/textual-paint}ansi")
|
||||||
|
if ansi_el is not None:
|
||||||
|
if ansi_el.text is None:
|
||||||
|
return AnsiArtDocument(1, 1, default_bg, default_fg)
|
||||||
|
text = base64.b64decode(ansi_el.text).decode("utf-8")
|
||||||
|
return AnsiArtDocument.from_ansi(text, default_bg, default_fg)
|
||||||
|
|
||||||
def add_debug_marker(x: float, y: float, color: str) -> None:
|
def add_debug_marker(x: float, y: float, color: str) -> None:
|
||||||
"""Adds a circle to the SVG at the given position, for debugging."""
|
"""Adds a circle to the SVG at the given position, for debugging."""
|
||||||
# without the namespace, it won't show up!
|
# without the namespace, it won't show up!
|
||||||
@ -2818,7 +2844,7 @@ class PaintApp(App[None]):
|
|||||||
# Note: image formats will lose any FOREGROUND color information.
|
# Note: image formats will lose any FOREGROUND color information.
|
||||||
# This could be considered part of the text information, but could be mentioned.
|
# This could be considered part of the text information, but could be mentioned.
|
||||||
# Also, it could be confusing if a file uses a lot of full block characters (█).
|
# Also, it could be confusing if a file uses a lot of full block characters (█).
|
||||||
# TODO: is this all the formats that can't be opened?
|
# TODO: warn about lossy JPEG encoding? or just disable the format!
|
||||||
non_openable = format_id in ("HTML", "RICH_CONSOLE_MARKUP") or (format_id in Image.SAVE and not format_id in Image.OPEN)
|
non_openable = format_id in ("HTML", "RICH_CONSOLE_MARKUP") or (format_id in Image.SAVE and not format_id in Image.OPEN)
|
||||||
supports_text_and_color = format_id in ("ANSI", "SVG", "HTML", "RICH_CONSOLE_MARKUP")
|
supports_text_and_color = format_id in ("ANSI", "SVG", "HTML", "RICH_CONSOLE_MARKUP")
|
||||||
if format_id == "PLAINTEXT":
|
if format_id == "PLAINTEXT":
|
||||||
|
Loading…
Reference in New Issue
Block a user