From 2289c15d9e60de3ffaa12908389c6f833b5bd9c9 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 26 Jul 2025 20:32:24 +0200 Subject: [PATCH 01/13] Fix text selection --- src/wsdiff.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wsdiff.py b/src/wsdiff.py index 442bf34..c42129d 100644 --- a/src/wsdiff.py +++ b/src/wsdiff.py @@ -231,6 +231,10 @@ MAIN_CSS = r''' .wsd-lineno.wsd-empty::after { content: ""; } + + .wsd-lineno, .wsd-left { + user-select: none; + } } @layer wsd-automatic-media-rule { From 8bd69916f25a89034f66e8dc06b950034780829c Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 27 Jul 2025 01:10:32 +0200 Subject: [PATCH 02/13] Add collapsing unchanged lines function --- src/wsdiff.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/wsdiff.py b/src/wsdiff.py index c42129d..52cf5bf 100644 --- a/src/wsdiff.py +++ b/src/wsdiff.py @@ -235,6 +235,33 @@ MAIN_CSS = r''' .wsd-lineno, .wsd-left { user-select: none; } + + /* Collapsing runs of unchanged lines */ + .wsd-collapse { + grid-column: 1 / span 4; + display: grid; + grid-template-columns: subgrid; + } + + .wsd-collapse-controls { + grid-column: 1 / span 4; + display: flex; + justify-content: center; + color: #a0a0a0; + + background-image: radial-gradient(#BBBBBB 1px, transparent 0); + background-size: 10px 10px; + background-position: center; + background-repeat: repeat-x; + } + + .wsd-collapse-controls > label { + background-color: #f8f8f8; + } + + .wsd-collapse:has(input[type="checkbox"]:checked) > span { + display: none; + } } @layer wsd-automatic-media-rule { @@ -245,6 +272,10 @@ MAIN_CSS = r''' grid-template-columns: min-content min-content 1fr; } + .wsd-collapse, .wsd-collapse-controls { + grid-column: 1 / span 3; + } + .wsd-lineno { padding-left: 1em; } @@ -532,7 +563,7 @@ class RecordFormatter(Formatter): if lineno_ours == lineno: break else: - self.lines.append(f'') + self.lines.append((True, f'')) if not change: change_class = '' @@ -574,10 +605,10 @@ class RecordFormatter(Formatter): line += '' line += '' - self.lines.append(line) + self.lines.append((change, line)) for _ours_empty, (lineno_theirs, _diff_theirs), change in diff: - self.lines.append(f'') + self.lines.append((True, f'')) def html_diff_content(old, new, lexer): diff = list(difflib._mdiff(old.splitlines(), new.splitlines())) @@ -588,7 +619,22 @@ def html_diff_content(old, new, lexer): fmt_r = RecordFormatter('right', diff) pygments.highlight(new, lexer, fmt_r) - return '\n'.join(chain.from_iterable(zip(fmt_l.lines, fmt_r.lines))) + out = [] + for change, group in groupby(zip(fmt_l.lines, fmt_r.lines), lambda pair: pair[0][0]): + context_len = 5 + collapse_len = 5 + group = list(group) + do_collapse = not change and len(group) > 2*context_len + collapse_len + for i, ((_change_left, line_left), (_change_right, line_right)) in enumerate(group): + context_len = 5 + collapse_len = 5 + if do_collapse and i == context_len: + out.append(f'
') + out.append(line_left) + out.append(line_right) + if do_collapse and i == len(group) - context_len - 1: + out.append('
') + return '\n'.join(out) def html_diff_block(old, new, filename, lexer, hide_filename=True): code = html_diff_content(old, new, lexer) From 13afaa4e4f9abd8f25a874fde7acd8aab967b780 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 27 Jul 2025 01:16:02 +0200 Subject: [PATCH 03/13] Expose folding parameters in CLI --- src/wsdiff.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/wsdiff.py b/src/wsdiff.py index 52cf5bf..f622f44 100644 --- a/src/wsdiff.py +++ b/src/wsdiff.py @@ -610,7 +610,7 @@ class RecordFormatter(Formatter): for _ours_empty, (lineno_theirs, _diff_theirs), change in diff: self.lines.append((True, f'')) -def html_diff_content(old, new, lexer): +def html_diff_content(old, new, lexer, context_len=5, fold_min=5): diff = list(difflib._mdiff(old.splitlines(), new.splitlines())) fmt_l = RecordFormatter('left', diff) @@ -621,13 +621,9 @@ def html_diff_content(old, new, lexer): out = [] for change, group in groupby(zip(fmt_l.lines, fmt_r.lines), lambda pair: pair[0][0]): - context_len = 5 - collapse_len = 5 group = list(group) - do_collapse = not change and len(group) > 2*context_len + collapse_len + do_collapse = not change and len(group) > 2*context_len + fold_min for i, ((_change_left, line_left), (_change_right, line_right)) in enumerate(group): - context_len = 5 - collapse_len = 5 if do_collapse and i == context_len: out.append(f'
') out.append(line_left) @@ -636,8 +632,8 @@ def html_diff_content(old, new, lexer): out.append('
') return '\n'.join(out) -def html_diff_block(old, new, filename, lexer, hide_filename=True): - code = html_diff_content(old, new, lexer) +def html_diff_block(old, new, filename, lexer, hide_filename=True, context_len=5, fold_min=5): + code = html_diff_content(old, new, lexer, context_len=context_len, fold_min=fold_min) filename = f'
‭{filename}
' if hide_filename: filename = '' @@ -657,6 +653,8 @@ def cli(): parser.add_argument('-L', '--list-lexers', action='store_true', help='List available lexers for -l/--lexer') parser.add_argument('-t', '--pagetitle', help='Override page title of output HTML file') parser.add_argument('-o', '--output', default=sys.stdout, type=argparse.FileType('w'), help='Name of output file (default: stdout)') + parser.add_argument('--context-len', type=int, default=5, help='Number of lines to always print around changes without folding') + parser.add_argument('--fold-min', type=int, default=5, help='Minimum number of unchanged lines beyond which to fold') parser.add_argument('--header', action='store_true', help='Only output HTML header with stylesheets and stuff, and no diff') parser.add_argument('--content', action='store_true', help='Only output HTML content, without header') parser.add_argument('--nofilename', action='store_true', help='Do not output file name headers') @@ -734,7 +732,8 @@ def cli(): except: lexer = get_lexer_by_name('text') - diff_blocks.append(html_diff_block(old_text, new_text, suffix, lexer, hide_filename=args.nofilename)) + diff_blocks.append(html_diff_block(old_text, new_text, suffix, lexer, hide_filename=args.nofilename, + context_len=args.context_len, fold_min=args.fold_min)) body = '\n'.join(diff_blocks) if args.content: From 36c0d21a369ed470020e3f650c8d13cbfd1009d7 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 27 Jul 2025 16:52:26 +0200 Subject: [PATCH 04/13] Make sure empty lines don't collapse in the output --- src/wsdiff.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wsdiff.py b/src/wsdiff.py index f622f44..13574b3 100644 --- a/src/wsdiff.py +++ b/src/wsdiff.py @@ -151,6 +151,7 @@ MAIN_CSS = r''' padding-left: calc(4em + 5px); text-indent: -4em; padding-top: 2px; + align-self: stretch; /* Make sure empty lines don't collapse */ } /* Make individual syntax tokens wrap anywhere */ From 8abe7fdf38db899c27207f6f107a85e80606de56 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 27 Jul 2025 16:52:42 +0200 Subject: [PATCH 05/13] Preserve empty lines at start and end of inputs --- src/wsdiff.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wsdiff.py b/src/wsdiff.py index 13574b3..bb19262 100644 --- a/src/wsdiff.py +++ b/src/wsdiff.py @@ -634,6 +634,7 @@ def html_diff_content(old, new, lexer, context_len=5, fold_min=5): return '\n'.join(out) def html_diff_block(old, new, filename, lexer, hide_filename=True, context_len=5, fold_min=5): + lexer.stripnl = False # Make pygments preserve leading and trailing empty lines. code = html_diff_content(old, new, lexer, context_len=context_len, fold_min=fold_min) filename = f'
‭{filename}
' if hide_filename: From 5fa293138c4b3a8086e35affb6c1e4c570e02ae5 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 27 Jul 2025 16:53:31 +0200 Subject: [PATCH 06/13] Bump version to v0.2.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dc85d23..a7673e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wsdiff" -version = "0.1.2" +version = "0.2.0" authors = [{name="jaseg", email="code@jaseg.de"}] description = "wsdiff is a tool that produces a syntax-highlighted, self-contained, static HTML file that will show a colored, syntax-highlighted diff of two files or folders without external dependencies or javascript." requires-python = ">=3.7" From 16de22ae197ba82a7e0f17e0cb68f4a199d0f0df Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 28 Jul 2025 00:46:43 +0200 Subject: [PATCH 07/13] Add dark mode --- pyproject.toml | 2 +- src/wsdiff.py | 226 +++++++++++++++++++++++++++---------------------- 2 files changed, 128 insertions(+), 100 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a7673e2..c407a22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -dependencies = ["pygments"] +dependencies = ["pygments", "witchhazel"] [project.urls] "Source" = "https://git.jaseg.de/wsdiff.git" diff --git a/src/wsdiff.py b/src/wsdiff.py index bb19262..00096fc 100644 --- a/src/wsdiff.py +++ b/src/wsdiff.py @@ -33,12 +33,16 @@ from collections import defaultdict from pathlib import Path import re from itertools import groupby, chain +from functools import lru_cache import pygments +from pygments.formatter import Formatter from pygments.formatters import HtmlFormatter from pygments.lexer import RegexLexer from pygments.lexers import get_lexer_by_name, guess_lexer_for_filename, get_all_lexers, LEXERS from pygments import token +from pygments.token import STANDARD_TYPES +import witchhazel DIFF_STYLE_TOGGLE = r'''
@@ -55,14 +59,92 @@ DIFF_STYLE_TOGGLE = r''' ''' MAIN_CSS = r''' + +@media (prefers-color-scheme: light) { + html { + --c-bg-primary: #ffffff; + --c-fg-primary: #000000; + --c-bg-auxiliary: #f8f8f8; + --c-fg-auxiliary: #a0a0a0; + --c-border-line: #e0e0e0; + --c-bg-insert: #ecfdf0; + --c-bg-delete: #fbe9eb; + --c-bg-delete-lineno: #f9d7dc; + --c-fg-delete-lineno: #ae969a; + --c-bg-delete-word: #fac5cd; + --c-fg-delete-word: #400000; + --c-fg-insert-word: #004000; + --c-bg-insert-word: #c7f0d2; + --c-fg-insert-lineno: #9bb0a1; + --c-bg-insert-lineno: #ddfbe6; + --c-bg-empty: #f0f0f0; + --c-fg-foldline: #bbbbbb; + --c-border-delete: #e0c8c8; /* pick a darker border color inside the light red gutter */ + } +} + +@media (prefers-color-scheme: dark) { + html { + --c-bg-primary: #010409; + --c-fg-primary: #a0a0a0; + --c-bg-auxiliary: #0d1117; + --c-fg-auxiliary: #f0f6fc; + --c-fg-foldline: #bbbbbb; + --c-border-line: #3d444d; + --c-bg-insert: #223738; + --c-bg-delete: #280d1f; + --c-bg-delete-lineno: #421632; + --c-fg-delete-lineno: #ae969a; + --c-bg-delete-word: #421632; + --c-fg-delete-word: #fac5cd; + --c-fg-insert-word: #c7f0d2; + --c-bg-insert-word: #325148; + --c-fg-insert-lineno: #9bb0a1; + --c-bg-insert-lineno: #325148; + --c-bg-empty: #080b0f; + --c-border-delete: #e0c8c8; + } +} + +@media print { + html { + /* Copy of the light theme, but we clip all light gray backgrounds to white. */ + --c-bg-primary: #ffffff; + --c-fg-primary: #000000; + --c-bg-auxiliary: #ffffff; + --c-fg-auxiliary: #a0a0a0; + --c-border-line: #e0e0e0; + --c-bg-insert: #ecfdf0; + --c-bg-delete: #fbe9eb; + --c-bg-delete-lineno: #f9d7dc; + --c-fg-delete-lineno: #ae969a; + --c-bg-delete-word: #fac5cd; + --c-fg-delete-word: #400000; + --c-fg-insert-word: #004000; + --c-bg-insert-word: #c7f0d2; + --c-fg-insert-lineno: #9bb0a1; + --c-bg-insert-lineno: #ddfbe6; + --c-bg-empty: #ffffff; + --c-fg-foldline: #bbbbbb; + --c-border-delete: #e0c8c8; + } +} + @layer wsd-base-style { + html { + background-color: var(--c-bg-primary); + height: 100%; + width: 100%; + } + #wsd-js-controls { display: none; - background-color: #f8f8f8; + color: var(--c-fg-primary); + background-color: var(--c-bg-auxiliary); padding: 5px 20px; font-size: 10pt; font-weight: bold; - border: 1px solid #e0e0e0; + border: 1px solid var(--c-border-line); position: sticky; top: 0; z-index: 1; @@ -79,8 +161,8 @@ MAIN_CSS = r''' } .wsd-file-title { - background-color: #f8f8f8; - border-bottom: solid 1px #e0e0e0; + background-color: var(--c-bg-auxiliary); + border-bottom: solid 1px var(--c-border-line); } } @@ -117,8 +199,8 @@ MAIN_CSS = r''' .wsd-file-container { font-family: monospace; font-size: 9pt; - background-color: #f8f8f8; - border: solid 1px #e0e0e0; + background-color: var(--c-bg-auxiliary); + border: solid 1px var(--c-border-line); margin: 15px; } @@ -140,11 +222,16 @@ MAIN_CSS = r''' direction: rtl; } + .wsd-diff-files { + color: var(--c-fg-primary); + } + .wsd-diff { + background-color: var(--c-bg-primary); overflow-x: auto; display: grid; align-items: start; - border-top: 1px solid #e0e0e0; + border-top: 1px solid var(--c-border-line); } .wsd-line { @@ -165,31 +252,31 @@ MAIN_CSS = r''' } .wsd-line.wsd-left.wsd-change, .wsd-line.wsd-left.wsd-insert { - background-color: #fbe9eb; + background-color: var(--c-bg-delete); } .wsd-line.wsd-right.wsd-change, .wsd-line.wsd-right.wsd-insert { - background-color: #ecfdf0; + background-color: var(--c-bg-insert); } .wsd-lineno.wsd-left.wsd-change, .wsd-lineno.wsd-left.wsd-insert { - background-color: #f9d7dc; - color: #ae969a; + background-color: var(--c-bg-delete-lineno); + color: var(--c-fg-delete-lineno); } .wsd-lineno.wsd-right.wsd-change, .wsd-lineno.wsd-right.wsd-insert { - background-color: #ddfbe6; - color: #9bb0a1; + background-color: var(--c-bg-insert-lineno); + color: var(--c-fg-insert-lineno); } .wsd-right > .wsd-word-change { - background-color: #c7f0d2; - color: #004000; + background-color: var(--c-bg-insert-word); + color: var(--c-fg-insert-word); } .wsd-left > .wsd-word-change { - background-color: #fac5cd; - color: #400000; + background-color: var(--c-bg-delete-word); + color: var(--c-fg-delete-word); } .wsd-lineno { @@ -200,22 +287,18 @@ MAIN_CSS = r''' overflow: clip; position: relative; text-align: right; - color: #a0a0a0; - background-color: #f8f8f8; - border-right: 1px solid #e0e0e0; + color: var(--c-fg-auxiliary); + background-color: var(--c-bg-auxiliary); + border-right: 1px solid var(--c-border-line); align-self: stretch; } - .wsd-lineno.wsd-change, .wsd-lineno.wsd-insert { - color: #000000; - } - .wsd-lineno::after { position: absolute; right: 0; content: "\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳"; white-space: pre; - color: #a0a0a0; + color: var(--c-fg-auxiliary); } /* Default rules for split diff for wide screens (laptops) */ @@ -224,7 +307,7 @@ MAIN_CSS = r''' } .wsd-empty { - background-color: #f0f0f0; + background-color: var(--c-bg-empty); align-self: stretch; } @@ -248,16 +331,16 @@ MAIN_CSS = r''' grid-column: 1 / span 4; display: flex; justify-content: center; - color: #a0a0a0; + color: var(--c-fg-auxiliary); - background-image: radial-gradient(#BBBBBB 1px, transparent 0); + background-image: radial-gradient(var(--c-fg-foldline) 1px, transparent 0); background-size: 10px 10px; background-position: center; background-repeat: repeat-x; } .wsd-collapse-controls > label { - background-color: #f8f8f8; + background-color: var(--c-bg-auxiliary); } .wsd-collapse:has(input[type="checkbox"]:checked) > span { @@ -300,7 +383,7 @@ MAIN_CSS = r''' content: ""; align-self: stretch; grid-column: 1; - border-right: 1px solid #e0e0e0; + border-right: 1px solid var(--c-border-line); margin-right: -6px; /* move border into column gap, and 1px over to align with other borders */ } @@ -308,12 +391,12 @@ MAIN_CSS = r''' content: ""; align-self: stretch; grid-column: 2; - border-left: 1px solid #e0c8c8; /* pick a darker border color inside the light red gutter */ + border-left: 1px solid var(--c-border-delete); margin-left: -5px; } .wsd-lineno.wsd-left.wsd-insert { - border-right: 1px solid #e0c8c8; + border-right: 1px solid var(--c-border-delete); } .wsd-lineno.wsd-right.wsd-change::after { @@ -359,7 +442,7 @@ MAIN_CSS = r''' } .wsd-lineno.wsd-left.wsd-empty { - background-color: #ddfbe6; + background-color: var(--c-bg-insert-lineno); } /* line continuation arrows only in right line number column */ @@ -369,7 +452,7 @@ MAIN_CSS = r''' .wsd-lineno.wsd-left.wsd-insert::before { content: ""; grid-column: 2; - border-left: 1px solid #e0c8c8; /* pick a darker border color inside the light red gutter */ + border-left: 1px solid var(--c-border-delete); /* pick a darker border color inside the light red gutter */ margin-left: -5px; } } @@ -465,70 +548,6 @@ HTML_TEMPLATE = r''' ''' -PYGMENTS_CSS = ''' -body .wsd-hll { background-color: #ffffcc } -body .wsd-c { color: #177500 } /* Comment */ -body .wsd-err { color: #000000 } /* Error */ -body .wsd-k { color: #A90D91 } /* Keyword */ -body .wsd-l { color: #1C01CE } /* Literal */ -body .wsd-n { color: #000000 } /* Name */ -body .wsd-o { color: #000000 } /* Operator */ -body .wsd-cm { color: #177500 } /* Comment.Multiline */ -body .wsd-cp { color: #633820 } /* Comment.Preproc */ -body .wsd-c1 { color: #177500 } /* Comment.Single */ -body .wsd-cs { color: #177500 } /* Comment.Special */ -body .wsd-kc { color: #A90D91 } /* Keyword.Constant */ -body .wsd-kd { color: #A90D91 } /* Keyword.Declaration */ -body .wsd-kn { color: #A90D91 } /* Keyword.Namespace */ -body .wsd-kp { color: #A90D91 } /* Keyword.Pseudo */ -body .wsd-kr { color: #A90D91 } /* Keyword.Reserved */ -body .wsd-kt { color: #A90D91 } /* Keyword.Type */ -body .wsd-ld { color: #1C01CE } /* Literal.Date */ -body .wsd-m { color: #1C01CE } /* Literal.Number */ -body .wsd-s { color: #C41A16 } /* Literal.String */ -body .wsd-na { color: #836C28 } /* Name.Attribute */ -body .wsd-nb { color: #A90D91 } /* Name.Builtin */ -body .wsd-nc { color: #3F6E75 } /* Name.Class */ -body .wsd-no { color: #000000 } /* Name.Constant */ -body .wsd-nd { color: #000000 } /* Name.Decorator */ -body .wsd-ni { color: #000000 } /* Name.Entity */ -body .wsd-ne { color: #000000 } /* Name.Exception */ -body .wsd-nf { color: #000000 } /* Name.Function */ -body .wsd-nl { color: #000000 } /* Name.Label */ -body .wsd-nn { color: #000000 } /* Name.Namespace */ -body .wsd-nx { color: #000000 } /* Name.Other */ -body .wsd-py { color: #000000 } /* Name.Property */ -body .wsd-nt { color: #000000 } /* Name.Tag */ -body .wsd-nv { color: #000000 } /* Name.Variable */ -body .wsd-ow { color: #000000 } /* Operator.Word */ -body .wsd-mb { color: #1C01CE } /* Literal.Number.Bin */ -body .wsd-mf { color: #1C01CE } /* Literal.Number.Float */ -body .wsd-mh { color: #1C01CE } /* Literal.Number.Hex */ -body .wsd-mi { color: #1C01CE } /* Literal.Number.Integer */ -body .wsd-mo { color: #1C01CE } /* Literal.Number.Oct */ -body .wsd-sb { color: #C41A16 } /* Literal.String.Backtick */ -body .wsd-sc { color: #2300CE } /* Literal.String.Char */ -body .wsd-sd { color: #C41A16 } /* Literal.String.Doc */ -body .wsd-s2 { color: #C41A16 } /* Literal.String.Double */ -body .wsd-se { color: #C41A16 } /* Literal.String.Escape */ -body .wsd-sh { color: #C41A16 } /* Literal.String.Heredoc */ -body .wsd-si { color: #C41A16 } /* Literal.String.Interpol */ -body .wsd-sx { color: #C41A16 } /* Literal.String.Other */ -body .wsd-sr { color: #C41A16 } /* Literal.String.Regex */ -body .wsd-s1 { color: #C41A16 } /* Literal.String.Single */ -body .wsd-ss { color: #C41A16 } /* Literal.String.Symbol */ -body .wsd-bp { color: #5B269A } /* Name.Builtin.Pseudo */ -body .wsd-vc { color: #000000 } /* Name.Variable.Class */ -body .wsd-vg { color: #000000 } /* Name.Variable.Global */ -body .wsd-vi { color: #000000 } /* Name.Variable.Instance */ -body .wsd-il { color: #1C01CE } /* Literal.Number.Integer.Long */ -''' - -from pygments.formatter import Formatter -from pygments.token import STANDARD_TYPES - -from functools import lru_cache - @lru_cache(maxsize=256) def get_token_class(ttype): while not (name := STANDARD_TYPES.get(ttype)): @@ -677,7 +696,16 @@ def cli(): if args.syntax_css: syntax_css = Path(args.syntax_css).read_text() else: - syntax_css = PYGMENTS_CSS + light_css = HtmlFormatter(classprefix='wsd-', style='xcode').get_style_defs() + dark_css = HtmlFormatter(classprefix='wsd-', style=witchhazel.WitchHazelStyle).get_style_defs() + + syntax_css = textwrap.dedent(f'''@media print, (prefers-color-scheme: light) {{ + {light_css} + }} + + @media (prefers-color-scheme: dark) {{ + {dark_css} + }}''') if args.header: print(string.Template(HTML_TEMPLATE).substitute( From 7b41e0110ed9d3fd3af6a3ebcb68bcb0f15ce95c Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 28 Jul 2025 00:47:32 +0200 Subject: [PATCH 08/13] Bump version to v0.3.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c407a22..269061b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wsdiff" -version = "0.2.0" +version = "0.3.0" authors = [{name="jaseg", email="code@jaseg.de"}] description = "wsdiff is a tool that produces a syntax-highlighted, self-contained, static HTML file that will show a colored, syntax-highlighted diff of two files or folders without external dependencies or javascript." requires-python = ">=3.7" From f5408c1c8b17cee909c9d64b96ba0839230a6a89 Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 28 Jul 2025 10:55:02 +0200 Subject: [PATCH 09/13] Add example --- example.html | 890 +++++++++++++++++++++++++++++++++++++++++++++++++ example.py | 113 +++++++ example_old.py | 109 ++++++ 3 files changed, 1112 insertions(+) create mode 100644 example.html create mode 100644 example.py create mode 100644 example_old.py diff --git a/example.html b/example.html new file mode 100644 index 0000000..3c62f4b --- /dev/null +++ b/example.html @@ -0,0 +1,890 @@ + + + + + + diff: example_old.py / example.py + + + + + + + + +
+
+ Split view + +
+
+
+
+
+
+
+ + +
+
+
‭example.py
+
+ 1#!/usr/bin/env python3 +1#!/usr/bin/env python3 +2 +2 +3import math +3import math +4import itertools +4import itertools +5import textwrap +5import textwrap +
+6 +6 +7import click +7import click +8from reedmuller import reedmuller +8from reedmuller import reedmuller +9 +9 +10 +10 +11class Tag: +11class Tag: +12 """ Helper class to ease creation of SVG. All API functions that create SVG allow you to substitute this with your +12 """ Helper class to ease creation of SVG. All API functions that create SVG allow you to substitute this with your +13 own implementation by passing a ``tag`` parameter. """ +13 own implementation by passing a ``tag`` parameter. """ +14 +14 +15 def __init__(self, name, children=None, root=False, **attrs): +15 def __init__(self, name, children=None, root=False, **attrs): +16 if (fill := attrs.get('fill')) and isinstance(fill, tuple): +16 if (fill := attrs.get('fill')) and isinstance(fill, tuple): +17 attrs['fill'], attrs['fill-opacity'] = fill +17 attrs['fill'], attrs['fill-opacity'] = fill +18 if (stroke := attrs.get('stroke')) and isinstance(stroke, tuple): +18 if (stroke := attrs.get('stroke')) and isinstance(stroke, tuple): +19 attrs['stroke'], attrs['stroke-opacity'] = stroke +19 attrs['stroke'], attrs['stroke-opacity'] = stroke +20 self.name, self.attrs = name, attrs +20 self.name, self.attrs = name, attrs +21 self.children = children or [] +21 self.children = children or [] +22 self.root = root +22 self.root = root +23 +23 +24 def __str__(self): +24 def __str__(self): +25 prefix = '<?xml version="1.0" encoding="utf-8"?>\n' if self.root else '' +25 prefix = '<?xml version="1.0" encoding="utf-8"?>\n' if self.root else '' +26 opening = ' '.join([self.name] + [f'{key.replace("__", ":").replace("_", "-")}="{value}"' for key, value in self.attrs.items()]) +26 opening = ' '.join([self.name] + [f'{key.replace("__", ":").replace("_", "-")}="{value}"' for key, value in self.attrs.items()]) +27 if self.children: +27 if self.children: +28 children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children) +28 children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children) +29 return f'{prefix}<{opening}>\n{children}\n</{self.name}>' +29 return f'{prefix}<{opening}>\n{children}\n</{self.name}>' +
+30 else: +30 else: +31 return f'{prefix}<{opening}/>' +31 return f'{prefix}<{opening}/>' +32 +32 +33 +33 +34 @classmethod +34 @classmethod +35 def setup_svg(kls, tags, bounds, margin=0, unit='mm', pagecolor='white'): +35 def setup_svg(kls, tags, bounds, unit='mm', pagecolor='white', inkscape=False): +36 (min_x, min_y), (max_x, max_y) = bounds +36 (min_x, min_y), (max_x, max_y) = bounds +37 + +38 if margin: + +39 min_x -= margin + +40 min_y -= margin + +41 max_x += margin + +42 max_y += margin + +43 +37 +44 w, h = max_x - min_x, max_y - min_y +38 w, h = max_x - min_x, max_y - min_y +45 w = 1.0 if math.isclose(w, 0.0) else w +39 w = 1.0 if math.isclose(w, 0.0) else w +46 h = 1.0 if math.isclose(h, 0.0) else h +40 h = 1.0 if math.isclose(h, 0.0) else h +47 +41 + +42 if inkscape: + +43 tags.insert(0, kls('sodipodi:namedview', [], id='namedview1', pagecolor=pagecolor, + +44 inkscape__document_units=unit)) +48 namespaces = dict( +45 namespaces = dict( +49 xmlns="http://www.w3.org/2000/svg", +46 xmlns="http://www.w3.org/2000/svg", + +47 xmlns__xlink="http://www.w3.org/1999/xlink", + +48 xmlns__sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd', + +49 xmlns__inkscape='http://www.inkscape.org/namespaces/inkscape') + +50 + +51 else: + +52 namespaces = dict( + +53 xmlns="http://www.w3.org/2000/svg", +50 xmlns__xlink="http://www.w3.org/1999/xlink") +54 xmlns__xlink="http://www.w3.org/1999/xlink") +51 +55 +52 return kls('svg', tags, +56 return kls('svg', tags, +53 width=f'{w}{unit}', height=f'{h}{unit}', +57 width=f'{w}{unit}', height=f'{h}{unit}', +54 viewBox=f'{min_x} {min_y} {w} {h}', +58 viewBox=f'{min_x} {min_y} {w} {h}', +55 style=f'background-color:{pagecolor}', +59 style=f'background-color:{pagecolor}', +
+56 **namespaces, +60 **namespaces, +57 root=True) +61 root=True) +58 +62 +59 +63 +60@click.command() +64@click.command() +61@click.option('-h', '--height', type=float, default=20, help='Bar height in mm') +65@click.option('-h', '--height', type=float, default=20, help='Bar height in mm') +62@click.option('-t/-n', '--text/--no-text', default=True, help='Whether to add text containing the data under the bar code') +66@click.option('-t/-n', '--text/--no-text', default=True, help='Whether to add text containing the data under the bar code') +63@click.option('-f', '--font', default='sans-serif', help='Font for the text underneath the bar code') +67@click.option('-f', '--font', default='sans-serif', help='Font for the text underneath the bar code') +64@click.option('-s', '--font-size', type=float, default=12, help='Font size for the text underneath the bar code in points (pt)') +68@click.option('-s', '--font-size', type=float, default=12, help='Font size for the text underneath the bar code in points (pt)') +65@click.option('-b', '--bar-width', type=float, default=1.0, help='Bar width in mm') +69@click.option('-b', '--bar-width', type=float, default=1.0, help='Bar width in mm') +66@click.option('-m', '--margin', type=float, default=3.0, help='Margin around bar code in mm') +70@click.option('-m', '--margin', type=float, default=3.0, help='Margin around bar code in mm') +67@click.option('-c', '--color', default='black', help='SVG color for the bar code') +71@click.option('-c', '--color', default='black', help='SVG color for the bar code') +68@click.option('--text-color', default=None, help='SVG color for the text (defaults to the bar code\'s color)') +72@click.option('--text-color', default=None, help='SVG color for the text (defaults to the bar code\'s color)') +69@click.option('--dpi', type=float, default=96, help='DPI value to assume for internal SVG unit conversions') +73@click.option('--dpi', type=float, default=96, help='DPI value to assume for internal SVG unit conversions') +70@click.argument('data') +74@click.argument('data') +71@click.argument('outfile', type=click.File('w'), default='-') +75@click.argument('outfile', type=click.File('w'), default='-') +72def cli(data, outfile, height, text, font, font_size, bar_width, margin, color, text_color, dpi): +76def cli(data, outfile, height, text, font, font_size, bar_width, margin, color, text_color, dpi): +73 data = int(data, 16) +77 data = int(data, 16) +74 text_color = text_color or color +78 text_color = text_color or color +75 +79 +76 NUM_BITS = 26 +80 NUM_BITS = 26 +77 +81 +78 data_bits = [bool(data & (1<<i)) for i in range(NUM_BITS)] +82 data_bits = [bool(data & (1<<i)) for i in range(NUM_BITS)] +79 data_encoded = itertools.chain(*[ +83 data_encoded = itertools.chain(*[ +80 (a, not a) for a in data_bits +84 (a, not a) for a in data_bits +81 ]) +85 ]) +82 data_encoded = [True, False, True, False, *data_encoded, False, True, True, False, True] +86 data_encoded = [True, False, True, False, *data_encoded, False, True, True, False, True] +83 +87 +84 width = len(data_encoded) * bar_width +88 width = len(data_encoded) * bar_width +85 # 1 px = 0.75 pt +89 # 1 px = 0.75 pt +86 pt_to_mm = lambda pt: pt / 0.75 /dpi * 25.4 +90 pt_to_mm = lambda pt: pt / 0.75 /dpi * 25.4 +87 font_size = pt_to_mm(font_size) +91 font_size = pt_to_mm(font_size) +88 total_height = height + font_size*2 +92 total_height = height + font_size*2 +89 +93 +90 tags = [] +94 tags = [] +91 for key, group in itertools.groupby(enumerate(data_encoded), key=lambda x: x[1]): +95 for key, group in itertools.groupby(enumerate(data_encoded), key=lambda x: x[1]): +92 if key: +96 if key: +93 group = list(group) +97 group = list(group) +94 x0, _key = group[0] +98 x0, _key = group[0] +95 w = len(group) +99 w = len(group) +96 tags.append(Tag('path', stroke=color, stroke_width=w, d=f'M {(x0 + w/2)*bar_width} 0 l 0 {height}')) +100 tags.append(Tag('path', stroke=color, stroke_width=w, d=f'M {(x0 + w/2)*bar_width} 0 l 0 {height}')) +97 +101 +98 if text: +102 if text: +99 tags.append(Tag('text', children=[f'{data:07x}'], +103 tags.append(Tag('text', children=[f'{data:07x}'], +100 x=width/2, y=height + 0.5*font_size, +104 x=width/2, y=height + 0.5*font_size, +101 font_family=font, font_size=f'{font_size:.3f}px', +105 font_family=font, font_size=f'{font_size:.3f}px', +102 text_anchor='middle', dominant_baseline='hanging', +106 text_anchor='middle', dominant_baseline='hanging', +103 fill=text_color)) +107 fill=text_color)) +104 +108 +
+105 outfile.write(str(Tag.setup_svg(tags, bounds=((0, 0), (width, total_height)), margin=margin))) +109 outfile.write(str(Tag.setup_svg(tags, bounds=((0, 0), (width, total_height)), margin=margin))) +106 +110 +107 +111 +108if __name__ == '__main__': +112if __name__ == '__main__': +109 cli() +113 cli() +
+
+
+ + + diff --git a/example.py b/example.py new file mode 100644 index 0000000..2e22e20 --- /dev/null +++ b/example.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 + +import math +import itertools +import textwrap + +import click +from reedmuller import reedmuller + + +class Tag: + """ Helper class to ease creation of SVG. All API functions that create SVG allow you to substitute this with your + own implementation by passing a ``tag`` parameter. """ + + def __init__(self, name, children=None, root=False, **attrs): + if (fill := attrs.get('fill')) and isinstance(fill, tuple): + attrs['fill'], attrs['fill-opacity'] = fill + if (stroke := attrs.get('stroke')) and isinstance(stroke, tuple): + attrs['stroke'], attrs['stroke-opacity'] = stroke + self.name, self.attrs = name, attrs + self.children = children or [] + self.root = root + + def __str__(self): + prefix = '\n' if self.root else '' + opening = ' '.join([self.name] + [f'{key.replace("__", ":").replace("_", "-")}="{value}"' for key, value in self.attrs.items()]) + if self.children: + children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children) + return f'{prefix}<{opening}>\n{children}\n' + else: + return f'{prefix}<{opening}/>' + + + @classmethod + def setup_svg(kls, tags, bounds, unit='mm', pagecolor='white', inkscape=False): + (min_x, min_y), (max_x, max_y) = bounds + + w, h = max_x - min_x, max_y - min_y + w = 1.0 if math.isclose(w, 0.0) else w + h = 1.0 if math.isclose(h, 0.0) else h + + if inkscape: + tags.insert(0, kls('sodipodi:namedview', [], id='namedview1', pagecolor=pagecolor, + inkscape__document_units=unit)) + namespaces = dict( + xmlns="http://www.w3.org/2000/svg", + xmlns__xlink="http://www.w3.org/1999/xlink", + xmlns__sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd', + xmlns__inkscape='http://www.inkscape.org/namespaces/inkscape') + + else: + namespaces = dict( + xmlns="http://www.w3.org/2000/svg", + xmlns__xlink="http://www.w3.org/1999/xlink") + + return kls('svg', tags, + width=f'{w}{unit}', height=f'{h}{unit}', + viewBox=f'{min_x} {min_y} {w} {h}', + style=f'background-color:{pagecolor}', + **namespaces, + root=True) + + +@click.command() +@click.option('-h', '--height', type=float, default=20, help='Bar height in mm') +@click.option('-t/-n', '--text/--no-text', default=True, help='Whether to add text containing the data under the bar code') +@click.option('-f', '--font', default='sans-serif', help='Font for the text underneath the bar code') +@click.option('-s', '--font-size', type=float, default=12, help='Font size for the text underneath the bar code in points (pt)') +@click.option('-b', '--bar-width', type=float, default=1.0, help='Bar width in mm') +@click.option('-m', '--margin', type=float, default=3.0, help='Margin around bar code in mm') +@click.option('-c', '--color', default='black', help='SVG color for the bar code') +@click.option('--text-color', default=None, help='SVG color for the text (defaults to the bar code\'s color)') +@click.option('--dpi', type=float, default=96, help='DPI value to assume for internal SVG unit conversions') +@click.argument('data') +@click.argument('outfile', type=click.File('w'), default='-') +def cli(data, outfile, height, text, font, font_size, bar_width, margin, color, text_color, dpi): + data = int(data, 16) + text_color = text_color or color + + NUM_BITS = 26 + + data_bits = [bool(data & (1<\n{children}\n' + else: + return f'{prefix}<{opening}/>' + + + @classmethod + def setup_svg(kls, tags, bounds, margin=0, unit='mm', pagecolor='white'): + (min_x, min_y), (max_x, max_y) = bounds + + if margin: + min_x -= margin + min_y -= margin + max_x += margin + max_y += margin + + w, h = max_x - min_x, max_y - min_y + w = 1.0 if math.isclose(w, 0.0) else w + h = 1.0 if math.isclose(h, 0.0) else h + + namespaces = dict( + xmlns="http://www.w3.org/2000/svg", + xmlns__xlink="http://www.w3.org/1999/xlink") + + return kls('svg', tags, + width=f'{w}{unit}', height=f'{h}{unit}', + viewBox=f'{min_x} {min_y} {w} {h}', + style=f'background-color:{pagecolor}', + **namespaces, + root=True) + + +@click.command() +@click.option('-h', '--height', type=float, default=20, help='Bar height in mm') +@click.option('-t/-n', '--text/--no-text', default=True, help='Whether to add text containing the data under the bar code') +@click.option('-f', '--font', default='sans-serif', help='Font for the text underneath the bar code') +@click.option('-s', '--font-size', type=float, default=12, help='Font size for the text underneath the bar code in points (pt)') +@click.option('-b', '--bar-width', type=float, default=1.0, help='Bar width in mm') +@click.option('-m', '--margin', type=float, default=3.0, help='Margin around bar code in mm') +@click.option('-c', '--color', default='black', help='SVG color for the bar code') +@click.option('--text-color', default=None, help='SVG color for the text (defaults to the bar code\'s color)') +@click.option('--dpi', type=float, default=96, help='DPI value to assume for internal SVG unit conversions') +@click.argument('data') +@click.argument('outfile', type=click.File('w'), default='-') +def cli(data, outfile, height, text, font, font_size, bar_width, margin, color, text_color, dpi): + data = int(data, 16) + text_color = text_color or color + + NUM_BITS = 26 + + data_bits = [bool(data & (1< Date: Mon, 28 Jul 2025 10:55:09 +0200 Subject: [PATCH 10/13] Fix collapse controls background color --- src/wsdiff.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wsdiff.py b/src/wsdiff.py index 00096fc..b0cd80b 100644 --- a/src/wsdiff.py +++ b/src/wsdiff.py @@ -337,6 +337,7 @@ MAIN_CSS = r''' background-size: 10px 10px; background-position: center; background-repeat: repeat-x; + background-color: var(--c-bg-auxiliary) } .wsd-collapse-controls > label { From ed8285192edd551d3904b8670b8ce49ebfa94440 Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 28 Jul 2025 10:55:45 +0200 Subject: [PATCH 11/13] Bump version to v0.3.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 269061b..a26ebe3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wsdiff" -version = "0.3.0" +version = "0.3.1" authors = [{name="jaseg", email="code@jaseg.de"}] description = "wsdiff is a tool that produces a syntax-highlighted, self-contained, static HTML file that will show a colored, syntax-highlighted diff of two files or folders without external dependencies or javascript." requires-python = ">=3.7" From 3bb0760be2c758cbaa4f21ad655b5311bbb40153 Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 28 Jul 2025 11:08:19 +0200 Subject: [PATCH 12/13] Fix leading newline --- src/wsdiff.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wsdiff.py b/src/wsdiff.py index b0cd80b..bf004cc 100644 --- a/src/wsdiff.py +++ b/src/wsdiff.py @@ -515,8 +515,7 @@ DIFF_STYLE_SCRIPT = r''' document.getElementById('wsd-js-controls').style = 'display: flex'; ''' -HTML_TEMPLATE = r''' - +HTML_TEMPLATE = r''' From 64b5f981130bfa39a966a0b2c7a8ee56618da1bb Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 28 Jul 2025 11:08:35 +0200 Subject: [PATCH 13/13] Bump version to v0.3.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a26ebe3..92d5b94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wsdiff" -version = "0.3.1" +version = "0.3.2" authors = [{name="jaseg", email="code@jaseg.de"}] description = "wsdiff is a tool that produces a syntax-highlighted, self-contained, static HTML file that will show a colored, syntax-highlighted diff of two files or folders without external dependencies or javascript." requires-python = ">=3.7"