figpack 0.3.20__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {figpack-0.3.20/figpack.egg-info → figpack-0.4.0}/PKG-INFO +3 -1
  2. {figpack-0.3.20 → figpack-0.4.0}/README.md +2 -0
  3. {figpack-0.3.20 → figpack-0.4.0}/figpack/__init__.py +1 -1
  4. {figpack-0.3.20 → figpack-0.4.0}/figpack/cli.py +19 -2
  5. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_bundle_utils.py +21 -2
  6. figpack-0.4.0/figpack/core/_figure_spec.py +281 -0
  7. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_file_handler.py +1 -1
  8. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_patch_figure.py +85 -4
  9. figpack-0.4.0/figpack/core/_remix_figure.py +338 -0
  10. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_revert_patch_figure.py +1 -1
  11. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_upload_bundle.py +4 -3
  12. figpack-0.3.20/figpack/figpack-figure-dist/assets/index-CSMGUquF.js → figpack-0.4.0/figpack/figpack-figure-dist/assets/index-BKMp8GZO.js +70 -70
  13. {figpack-0.3.20 → figpack-0.4.0}/figpack/figpack-figure-dist/index.html +1 -1
  14. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Box.py +24 -0
  15. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/CaptionedView.py +11 -0
  16. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Gallery.py +20 -0
  17. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/MountainLayout.py +15 -0
  18. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/MultiChannelTimeseries.py +6 -0
  19. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Splitter.py +30 -0
  20. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/TabLayout.py +22 -1
  21. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/TimeseriesGraph.py +68 -0
  22. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/VerticalLayout.py +19 -0
  23. {figpack-0.3.20 → figpack-0.4.0/figpack.egg-info}/PKG-INFO +3 -1
  24. {figpack-0.3.20 → figpack-0.4.0}/figpack.egg-info/SOURCES.txt +5 -2
  25. figpack-0.4.0/figpack.egg-info/top_level.txt +1 -0
  26. {figpack-0.3.20 → figpack-0.4.0}/pyproject.toml +1 -1
  27. figpack-0.4.0/tests/test_figure_spec.py +130 -0
  28. figpack-0.4.0/tests/test_patch_pages.py +55 -0
  29. {figpack-0.3.20 → figpack-0.4.0}/tests/test_tablayout.py +1 -1
  30. figpack-0.3.20/figpack-serve/worker/container/app.py +0 -249
  31. figpack-0.3.20/figpack.egg-info/top_level.txt +0 -3
  32. {figpack-0.3.20 → figpack-0.4.0}/LICENSE +0 -0
  33. {figpack-0.3.20 → figpack-0.4.0}/MANIFEST.in +0 -0
  34. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/__init__.py +0 -0
  35. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_figure_utils.py +0 -0
  36. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_save_figure.py +0 -0
  37. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_server_manager.py +0 -0
  38. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_show_view.py +0 -0
  39. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_view_figure.py +0 -0
  40. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_zarr_consolidate.py +0 -0
  41. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/config.py +0 -0
  42. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/extension_view.py +0 -0
  43. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/figpack_extension.py +0 -0
  44. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/figpack_view.py +0 -0
  45. {figpack-0.3.20 → figpack-0.4.0}/figpack/core/zarr.py +0 -0
  46. {figpack-0.3.20 → figpack-0.4.0}/figpack/extensions.py +0 -0
  47. {figpack-0.3.20 → figpack-0.4.0}/figpack/figpack-figure-dist/assets/index-GPjx4QpG.css +0 -0
  48. {figpack-0.3.20 → figpack-0.4.0}/figpack/figpack-figure-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
  49. {figpack-0.3.20 → figpack-0.4.0}/figpack/utils/__init__.py +0 -0
  50. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/DataFrame.py +0 -0
  51. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/GalleryItem.py +0 -0
  52. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Iframe.py +0 -0
  53. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Image.py +0 -0
  54. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/LayoutItem.py +0 -0
  55. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Markdown.py +0 -0
  56. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/MatplotlibFigure.py +0 -0
  57. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/MountainLayoutItem.py +0 -0
  58. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/PlotlyExtension/PlotlyExtension.py +0 -0
  59. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/PlotlyExtension/__init__.py +0 -0
  60. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/PlotlyExtension/_plotly_extension.py +0 -0
  61. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/PlotlyExtension/plotly_view.js +0 -0
  62. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Spectrogram.py +0 -0
  63. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/TabLayoutItem.py +0 -0
  64. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/VerticalLayoutItem.py +0 -0
  65. {figpack-0.3.20 → figpack-0.4.0}/figpack/views/__init__.py +0 -0
  66. {figpack-0.3.20 → figpack-0.4.0}/figpack.egg-info/dependency_links.txt +0 -0
  67. {figpack-0.3.20 → figpack-0.4.0}/figpack.egg-info/entry_points.txt +0 -0
  68. {figpack-0.3.20 → figpack-0.4.0}/figpack.egg-info/requires.txt +0 -0
  69. {figpack-0.3.20 → figpack-0.4.0}/setup.cfg +0 -0
  70. {figpack-0.3.20 → figpack-0.4.0}/tests/test_box.py +0 -0
  71. {figpack-0.3.20 → figpack-0.4.0}/tests/test_cli.py +0 -0
  72. {figpack-0.3.20 → figpack-0.4.0}/tests/test_core.py +0 -0
  73. {figpack-0.3.20 → figpack-0.4.0}/tests/test_dataframe.py +0 -0
  74. {figpack-0.3.20 → figpack-0.4.0}/tests/test_extension_system.py +0 -0
  75. {figpack-0.3.20 → figpack-0.4.0}/tests/test_figpack_view.py +0 -0
  76. {figpack-0.3.20 → figpack-0.4.0}/tests/test_file_handler.py +0 -0
  77. {figpack-0.3.20 → figpack-0.4.0}/tests/test_gallery.py +0 -0
  78. {figpack-0.3.20 → figpack-0.4.0}/tests/test_image.py +0 -0
  79. {figpack-0.3.20 → figpack-0.4.0}/tests/test_markdown.py +0 -0
  80. {figpack-0.3.20 → figpack-0.4.0}/tests/test_matplotlib_figure.py +0 -0
  81. {figpack-0.3.20 → figpack-0.4.0}/tests/test_multichannel_timeseries.py +0 -0
  82. {figpack-0.3.20 → figpack-0.4.0}/tests/test_plotly_figure.py +0 -0
  83. {figpack-0.3.20 → figpack-0.4.0}/tests/test_server_manager.py +0 -0
  84. {figpack-0.3.20 → figpack-0.4.0}/tests/test_spectrogram.py +0 -0
  85. {figpack-0.3.20 → figpack-0.4.0}/tests/test_splitter.py +0 -0
  86. {figpack-0.3.20 → figpack-0.4.0}/tests/test_timeseries_graph.py +0 -0
  87. {figpack-0.3.20 → figpack-0.4.0}/tests/test_upload_bundle.py +0 -0
  88. {figpack-0.3.20 → figpack-0.4.0}/tests/test_view_figure.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: figpack
3
- Version: 0.3.20
3
+ Version: 0.4.0
4
4
  Summary: A Python package for creating shareable, interactive visualizations in the browser
5
5
  Author-email: Jeremy Magland <jmagland@flatironinstitute.org>
6
6
  License: Apache-2.0
@@ -86,6 +86,8 @@ A Python package for creating shareable, interactive visualizations in the brows
86
86
 
87
87
  For detailed guidance, tutorials, and API reference, visit our **[documentation](https://flatironinstitute.github.io/figpack)**.
88
88
 
89
+ [Figpack for the Curious](https://jeremy.magland.org/posts/2026-10-02-figpack-for-the-curious/) (blog post)
90
+
89
91
  ## Quick Start
90
92
 
91
93
  Want to jump right in? Here's how to get started:
@@ -10,6 +10,8 @@ A Python package for creating shareable, interactive visualizations in the brows
10
10
 
11
11
  For detailed guidance, tutorials, and API reference, visit our **[documentation](https://flatironinstitute.github.io/figpack)**.
12
12
 
13
+ [Figpack for the Curious](https://jeremy.magland.org/posts/2026-10-02-figpack-for-the-curious/) (blog post)
14
+
13
15
  ## Quick Start
14
16
 
15
17
  Want to jump right in? Here's how to get started:
@@ -2,7 +2,7 @@
2
2
  figpack - A Python package for creating shareable, interactive visualizations in the browser
3
3
  """
4
4
 
5
- __version__ = "0.3.20"
5
+ __version__ = "0.4.0"
6
6
 
7
7
  from .cli import view_figure
8
8
  from .core import FigpackView, FigpackExtension, ExtensionView
@@ -18,6 +18,7 @@ import requests
18
18
 
19
19
  from . import __version__
20
20
  from .core._view_figure import view_figure
21
+ from .core._remix_figure import remix_figure
21
22
  from .core._patch_figure import patch_figure as core_patch_figure
22
23
  from .core._revert_patch_figure import revert_patch_figure as core_revert_patch_figure
23
24
  from .core._figure_utils import get_figure_base_url, download_file
@@ -430,7 +431,7 @@ def upload_figure(dir_path: str, title: str, description: str = None) -> None:
430
431
 
431
432
  for file_path in dir_pathlib.rglob("*"):
432
433
  if file_path.is_file():
433
- rel_path = str(file_path.relative_to(dir_pathlib))
434
+ rel_path = file_path.relative_to(dir_pathlib).as_posix()
434
435
  file_size = file_path.stat().st_size
435
436
  manifest["files"].append({"path": rel_path, "size": file_size})
436
437
  manifest["total_size"] += file_size
@@ -553,7 +554,7 @@ def _old_revert_figure(
553
554
  for item in backup_subdir.rglob("*"):
554
555
  if item.is_file() and item.name != "backup_info.json":
555
556
  rel_path = item.relative_to(backup_subdir)
556
- backed_up_files.append(str(rel_path))
557
+ backed_up_files.append(rel_path.as_posix())
557
558
  else:
558
559
  with open(backup_info_path) as f:
559
560
  backup_info = json.load(f)
@@ -715,6 +716,20 @@ def main():
715
716
  "--port", type=int, help="Port number to serve on (default: auto-select)"
716
717
  )
717
718
 
719
+ # Remix command
720
+ remix_parser = subparsers.add_parser(
721
+ "remix",
722
+ help="Download a figure's index.html and serve it locally for remixing",
723
+ )
724
+ remix_parser.add_argument("figure_url", help="The figpack URL to remix")
725
+ remix_parser.add_argument(
726
+ "--dir",
727
+ help="Local directory for the remix (default: ~/.figpack/remixes/<figure id>)",
728
+ )
729
+ remix_parser.add_argument(
730
+ "--port", type=int, help="Port number to serve on (default: auto-select)"
731
+ )
732
+
718
733
  # Patch figure command
719
734
  patch_figure_parser = subparsers.add_parser(
720
735
  "patch-figure", help="Update a figure's rendering code to the latest version"
@@ -797,6 +812,8 @@ def main():
797
812
  download_and_view_archive(args.archive, port=args.port)
798
813
  else:
799
814
  view_figure(args.archive, port=args.port)
815
+ elif args.command == "remix":
816
+ remix_figure(args.figure_url, dir_path=args.dir, port=args.port)
800
817
  elif args.command == "patch-figure":
801
818
  patch_figure(args.figure_url, admin_override=args.admin_override)
802
819
  elif args.command == "revert-patch-figure":
@@ -10,6 +10,11 @@ from .figpack_extension import FigpackExtension
10
10
  from .extension_view import ExtensionView
11
11
  from .zarr import Group, _check_zarr_version
12
12
  from ._zarr_consolidate import consolidate_zarr_chunks
13
+ from ._figure_spec import (
14
+ build_figure_spec,
15
+ build_figure_spec_docs,
16
+ insert_figure_spec_into_html,
17
+ )
13
18
 
14
19
  thisdir = pathlib.Path(__file__).parent.resolve()
15
20
 
@@ -28,8 +33,9 @@ def prepare_figure_bundle(
28
33
  This function:
29
34
  1. Copies all files from the figpack-figure-dist directory to tmpdir
30
35
  2. Writes the view data to a zarr group
31
- 3. Discovers and writes extension JavaScript files
32
- 4. Consolidates zarr metadata
36
+ 3. Writes the figure spec (layout and settings) into index.html
37
+ 4. Discovers and writes extension JavaScript files
38
+ 5. Consolidates zarr metadata
33
39
 
34
40
  Args:
35
41
  view: The figpack view to prepare
@@ -74,6 +80,19 @@ def prepare_figure_bundle(
74
80
  if script:
75
81
  zarr_group.attrs["script"] = script
76
82
 
83
+ # Write the figure spec (layout and settings) into index.html
84
+ index_html_path = pathlib.Path(tmpdir) / "index.html"
85
+ figure_spec = build_figure_spec(zarr_group._zarr_group)
86
+ figure_spec_docs = build_figure_spec_docs(zarr_group._zarr_group)
87
+ index_html_path.write_text(
88
+ insert_figure_spec_into_html(
89
+ index_html_path.read_text(encoding="utf-8"),
90
+ figure_spec,
91
+ figure_spec_docs,
92
+ ),
93
+ encoding="utf-8",
94
+ )
95
+
77
96
  # Discover and write extension JavaScript files
78
97
  required_extensions = _discover_required_extensions(view)
79
98
  _write_extension_files(required_extensions, tmpdir)
@@ -0,0 +1,281 @@
1
+ """
2
+ Figure spec written into index.html
3
+
4
+ The spec lists the layout and the user-facing settings of a figure, on top of
5
+ the data in data.zarr. Each node's props are merged over the attributes of the
6
+ matching zarr group by the frontend (figpack-figure/src/figureSpec.ts), so
7
+ editing a prop value in index.html changes the figure.
8
+
9
+ Which attributes appear is decided by per-view schemas (SpecSchema), declared
10
+ as the `spec_schema` class attribute of view classes. Attributes that describe
11
+ the stored data (sizes, sampling rates, downsampling factors, ...) are left
12
+ out, and so are views without a schema. A short reference for the view types
13
+ used in the figure is written into index.html next to the spec, so the figure
14
+ stays self-contained.
15
+ """
16
+
17
+ import json
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Dict, List, Literal, Optional
20
+
21
+ FIGURE_SPEC_VERSION = 1
22
+
23
+ # Warn when the spec gets large, since it is meant to be read and edited (often
24
+ # by an AI agent, where it costs input tokens)
25
+ SPEC_SIZE_WARNING_CHARS = 20000
26
+
27
+ # Line width used when formatting the spec JSON
28
+ _SPEC_LINE_WIDTH = 120
29
+
30
+ # Props of the root node that belong to the figure rather than its root view
31
+ _FIGURE_PROPS = {"title"}
32
+
33
+
34
+ @dataclass
35
+ class SpecProp:
36
+ """A user-facing attribute of a view (or of a child group of a view)"""
37
+
38
+ name: str
39
+ type: str
40
+ description: str
41
+
42
+
43
+ @dataclass
44
+ class SpecSchema:
45
+ """
46
+ Describes which zarr attributes of a view are exposed in the figure spec
47
+
48
+ children:
49
+ "none": the view has no configurable child groups
50
+ "views": child groups are views (e.g. layouts); each uses its own schema
51
+ "groups": child groups are configurable parts of this view (e.g. the
52
+ series of a TimeseriesGraph), with props given by child_props
53
+ """
54
+
55
+ view_type: str
56
+ description: str
57
+ props: List[SpecProp] = field(default_factory=list)
58
+ children: Literal["none", "views", "groups"] = "none"
59
+ children_description: str = ""
60
+ child_props: List[SpecProp] = field(default_factory=list)
61
+
62
+
63
+ def get_spec_schemas() -> Dict[str, SpecSchema]:
64
+ """Collect the spec schemas of all imported view classes, keyed by view type"""
65
+ from .figpack_view import FigpackView
66
+
67
+ schemas: Dict[str, SpecSchema] = {}
68
+ stack = list(FigpackView.__subclasses__())
69
+ seen = set()
70
+ while stack:
71
+ cls = stack.pop()
72
+ if cls in seen:
73
+ continue
74
+ seen.add(cls)
75
+ stack.extend(cls.__subclasses__())
76
+ schema = cls.__dict__.get("spec_schema")
77
+ if isinstance(schema, SpecSchema):
78
+ schemas[schema.view_type] = schema
79
+ return schemas
80
+
81
+
82
+ def build_figure_spec(
83
+ zarr_group, schemas: Optional[Dict[str, SpecSchema]] = None
84
+ ) -> Dict[str, Any]:
85
+ """
86
+ Build the figure spec from the (raw) root zarr group of a figure
87
+
88
+ Args:
89
+ zarr_group: The root zarr group of the figure
90
+ schemas: Spec schemas by view type (default: get_spec_schemas())
91
+
92
+ Returns:
93
+ The spec as a JSON-serializable dict
94
+ """
95
+ if schemas is None:
96
+ schemas = get_spec_schemas()
97
+ node = _build_view_node(zarr_group, schemas)
98
+ figure_props = {
99
+ k: zarr_group.attrs[k] for k in sorted(_FIGURE_PROPS) if k in zarr_group.attrs
100
+ }
101
+ if figure_props:
102
+ node["props"] = {**figure_props, **node.get("props", {})}
103
+ return node
104
+
105
+
106
+ def _select_props(attrs, props: List[SpecProp]) -> Dict[str, Any]:
107
+ return {p.name: attrs[p.name] for p in props if p.name in attrs}
108
+
109
+
110
+ def _build_view_node(group, schemas: Dict[str, SpecSchema]) -> Dict[str, Any]:
111
+ schema = schemas.get(group.attrs.get("view_type", ""))
112
+ if schema is None:
113
+ return {}
114
+ node: Dict[str, Any] = {}
115
+ props = _select_props(group.attrs, schema.props)
116
+ if props:
117
+ node["props"] = props
118
+ children = {}
119
+ for name, subgroup in group.groups():
120
+ if schema.children == "views":
121
+ child = _build_view_node(subgroup, schemas)
122
+ elif schema.children == "groups":
123
+ child_props = _select_props(subgroup.attrs, schema.child_props)
124
+ child = {"props": child_props} if child_props else {}
125
+ else:
126
+ child = {}
127
+ if child:
128
+ children[name] = child
129
+ if children:
130
+ node["children"] = children
131
+ return node
132
+
133
+
134
+ def _collect_view_types(zarr_group) -> List[str]:
135
+ view_types = []
136
+
137
+ def visit(group):
138
+ vt = group.attrs.get("view_type")
139
+ if vt and vt not in view_types:
140
+ view_types.append(vt)
141
+ for _, subgroup in group.groups():
142
+ visit(subgroup)
143
+
144
+ visit(zarr_group)
145
+ return view_types
146
+
147
+
148
+ def build_figure_spec_docs(
149
+ zarr_group, schemas: Optional[Dict[str, SpecSchema]] = None
150
+ ) -> str:
151
+ """
152
+ Build a short markdown reference for the spec, covering the view types
153
+ used in the figure
154
+
155
+ Args:
156
+ zarr_group: The root zarr group of the figure
157
+ schemas: Spec schemas by view type (default: get_spec_schemas())
158
+
159
+ Returns:
160
+ The reference as markdown text
161
+ """
162
+ if schemas is None:
163
+ schemas = get_spec_schemas()
164
+ lines = [
165
+ f"# Figure spec reference (spec version {FIGURE_SPEC_VERSION})",
166
+ "",
167
+ "The figpack.render(spec) script below defines this figure's layout and settings on top of the data in data.zarr.",
168
+ "A spec node is {data?, view_type?, props?, children?}:",
169
+ "- props: values that replace the attributes of the node's zarr group (whole values are replaced; objects and lists are not merged).",
170
+ "- children: child nodes by name, matched to the subgroups of the node's zarr group.",
171
+ '- data: path of a group in data.zarr for this node (e.g. "/item_0"), or null for a node defined only by the spec (then view_type is required). If omitted, the node uses its counterpart in the original figure: the root group, or the parent\'s subgroup with the same name.',
172
+ "- view_type: the view type of the node.",
173
+ "Only the props documented here are listed in the spec. Other attributes in data.zarr describe the stored data and should not be changed.",
174
+ "Root node: props.title (string) is the figure title.",
175
+ ]
176
+ view_types = _collect_view_types(zarr_group)
177
+ documented = [vt for vt in view_types if vt in schemas]
178
+ for vt in documented:
179
+ schema = schemas[vt]
180
+ lines += ["", f"## {vt}", "", schema.description]
181
+ if schema.props:
182
+ lines.append("Props:")
183
+ lines += [f"- {p.name} ({p.type}): {p.description}" for p in schema.props]
184
+ else:
185
+ lines.append("No configurable props.")
186
+ if schema.children_description:
187
+ lines.append(f"Children: {schema.children_description}")
188
+ if schema.child_props:
189
+ lines.append("Child props:")
190
+ lines += [
191
+ f"- {p.name} ({p.type}): {p.description}" for p in schema.child_props
192
+ ]
193
+ undocumented = [vt for vt in view_types if vt not in schemas]
194
+ if undocumented:
195
+ lines += [
196
+ "",
197
+ "View types without documented props (they are rendered from data.zarr as is): "
198
+ + ", ".join(undocumented),
199
+ ]
200
+ return "\n".join(lines) + "\n"
201
+
202
+
203
+ def format_spec_json(value: Any, *, indent: int = 0, prefix_len: int = 0) -> str:
204
+ """
205
+ Format JSON with one entry per line, except that values short enough to
206
+ fit on the current line are written inline
207
+
208
+ Args:
209
+ value: JSON-serializable value
210
+ indent: Current indentation (spaces)
211
+ prefix_len: Length of the text before the value on its first line
212
+
213
+ Returns:
214
+ The formatted JSON
215
+ """
216
+ compact = json.dumps(value, ensure_ascii=False)
217
+ if (
218
+ not isinstance(value, (dict, list))
219
+ or not value
220
+ or indent + prefix_len + len(compact) <= _SPEC_LINE_WIDTH
221
+ ):
222
+ return compact
223
+ pad = " " * (indent + 2)
224
+ if isinstance(value, dict):
225
+ entries = []
226
+ for k, v in value.items():
227
+ key = json.dumps(k, ensure_ascii=False) + ": "
228
+ entries.append(
229
+ pad + key + format_spec_json(v, indent=indent + 2, prefix_len=len(key))
230
+ )
231
+ return "{\n" + ",\n".join(entries) + "\n" + " " * indent + "}"
232
+ entries = [pad + format_spec_json(v, indent=indent + 2) for v in value]
233
+ return "[\n" + ",\n".join(entries) + "\n" + " " * indent + "]"
234
+
235
+
236
+ def _escape_for_script(text: str) -> str:
237
+ # Make text safe to embed in a <script> element
238
+ return (
239
+ text.replace("</", "<\\/")
240
+ .replace("\u2028", "\\u2028")
241
+ .replace("\u2029", "\\u2029")
242
+ )
243
+
244
+
245
+ def insert_figure_spec_into_html(
246
+ html: str, spec: Dict[str, Any], docs: Optional[str] = None
247
+ ) -> str:
248
+ """
249
+ Insert the spec reference and a figpack.render(spec) script at the end of
250
+ the body of index.html
251
+
252
+ Args:
253
+ html: The contents of index.html
254
+ spec: The figure spec
255
+ docs: Optional markdown reference for the spec
256
+
257
+ Returns:
258
+ The updated html
259
+ """
260
+ spec_json = _escape_for_script(format_spec_json(spec, indent=4))
261
+ if len(spec_json) > SPEC_SIZE_WARNING_CHARS:
262
+ print(
263
+ f"Warning: the figure spec in index.html is {len(spec_json)} characters "
264
+ f"(more than {SPEC_SIZE_WARNING_CHARS})"
265
+ )
266
+ script = ""
267
+ if docs:
268
+ script += (
269
+ ' <script type="text/markdown" data-figpack-spec-docs>\n'
270
+ + _escape_for_script(docs)
271
+ + " </script>\n"
272
+ )
273
+ script += (
274
+ ' <script type="module" data-figpack-spec>\n'
275
+ + f" figpack.render({spec_json}, {{ specVersion: {FIGURE_SPEC_VERSION} }});\n"
276
+ + " </script>\n"
277
+ )
278
+ idx = html.rfind("</body>")
279
+ if idx < 0:
280
+ return html + script
281
+ return html[:idx] + script + html[idx:]
@@ -81,7 +81,7 @@ class FileUploadCORSRequestHandler(CORSRequestHandler):
81
81
  self.send_header("Content-Type", "application/json")
82
82
  self.end_headers()
83
83
 
84
- response_data = f'{{"status": "success", "path": "{file_path.relative_to(pathlib.Path(self.directory))}"}}'
84
+ response_data = f'{{"status": "success", "path": "{file_path.relative_to(pathlib.Path(self.directory)).as_posix()}"}}'
85
85
  self.wfile.write(response_data.encode("utf-8"))
86
86
 
87
87
  except Exception as e:
@@ -9,7 +9,10 @@ Core functionality for patching figpack figures
9
9
  import json
10
10
  import os
11
11
  import pathlib
12
+ import re
13
+ import tempfile
12
14
  import time
15
+ from typing import Optional, Tuple
13
16
  from urllib.parse import urljoin
14
17
 
15
18
  import requests
@@ -18,6 +21,58 @@ from .. import __version__
18
21
  from .config import FIGPACK_API_BASE_URL
19
22
  from ._figure_utils import get_figure_base_url
20
23
 
24
+ # A reference to a hashed bundle asset in a src/href attribute, e.g.
25
+ # src="./assets/index-CSMGUquF.js" (Vite names assets <name>-<8-char hash>.<ext>)
26
+ _ASSET_REF_RE = re.compile(
27
+ r"""((?:src|href)=["'])((?:\./)?assets/)([A-Za-z0-9_.-]+)-([A-Za-z0-9_-]{8})\.([A-Za-z0-9]+)(["'])"""
28
+ )
29
+
30
+
31
+ def swap_bundle_references(page_html: str, new_index_html: str) -> Tuple[str, int]:
32
+ """
33
+ Point the bundle asset references of a figure page to the assets of a new
34
+ figpack-figure bundle, leaving the rest of the page (such as the figure
35
+ spec, its reference docs, and any edits) unchanged
36
+
37
+ Args:
38
+ page_html: The html of the figure page to patch
39
+ new_index_html: The index.html of the new figpack-figure bundle
40
+
41
+ Returns:
42
+ The patched html and the number of references replaced
43
+ """
44
+ new_refs = {}
45
+ for m in _ASSET_REF_RE.finditer(new_index_html):
46
+ new_refs[(m.group(3), m.group(5))] = m.group(3) + "-" + m.group(4)
47
+
48
+ count = 0
49
+
50
+ def replace(m):
51
+ nonlocal count
52
+ key = (m.group(3), m.group(5))
53
+ if key not in new_refs:
54
+ return m.group(0)
55
+ count += 1
56
+ return m.group(1) + m.group(2) + new_refs[key] + "." + m.group(5) + m.group(6)
57
+
58
+ return _ASSET_REF_RE.sub(replace, page_html), count
59
+
60
+
61
+ def _patched_page_html(path: str, page_html: str, new_index_html: str) -> Optional[str]:
62
+ """
63
+ The patched contents of a figure page, or None if it cannot be patched
64
+
65
+ An index.html without a figure spec (from figpack versions before figure
66
+ specs) is replaced by the new bundle's index.html, as before. Other pages
67
+ keep their contents, with the bundle references swapped.
68
+ """
69
+ if path == "index.html" and "data-figpack-spec" not in page_html:
70
+ return new_index_html
71
+ patched, count = swap_bundle_references(page_html, new_index_html)
72
+ if count == 0:
73
+ return None
74
+ return patched
75
+
21
76
 
22
77
  def patch_figure(
23
78
  figure_url: str,
@@ -93,7 +148,7 @@ def patch_figure(
93
148
  if admin_override and figure_owner:
94
149
  print(f"⚠️ ADMIN OVERRIDE: Modifying figure owned by {figure_owner}")
95
150
  print(
96
- f"This will update the rendering code (index.html, assets/*, extension-*.js)"
151
+ f"This will update the rendering code (assets/*, and the bundle references in the figure's .html pages)"
97
152
  )
98
153
  print(f"to figpack version {__version__}")
99
154
  print(
@@ -128,7 +183,7 @@ def patch_figure(
128
183
  file_path = file_info["path"]
129
184
  # Only backup rendering files
130
185
  if (
131
- file_path == "index.html"
186
+ file_path.endswith(".html")
132
187
  or file_path.startswith("assets/")
133
188
  or file_path.startswith("extension-")
134
189
  or file_path == "extension_manifest.json"
@@ -205,8 +260,36 @@ def patch_figure(
205
260
  # Collect all files to upload
206
261
  files_to_upload = []
207
262
 
263
+ new_index_html = (html_dir / "index.html").read_text(encoding="utf-8")
264
+ patched_pages_dir = pathlib.Path(tempfile.mkdtemp(prefix="figpack_patch_"))
265
+ page_paths = [
266
+ f["path"]
267
+ for f in rendering_files
268
+ if f["path"].endswith(".html") and f["path"] not in failed_backups
269
+ ]
270
+ if "index.html" not in page_paths:
271
+ page_paths.append("index.html")
272
+ for page_path in page_paths:
273
+ backup_page = backup_subdir / page_path
274
+ page_html = (
275
+ backup_page.read_text(encoding="utf-8") if backup_page.exists() else ""
276
+ )
277
+ patched = _patched_page_html(page_path, page_html, new_index_html)
278
+ if patched is None:
279
+ if verbose:
280
+ print(
281
+ f"Warning: {page_path} has no bundle references to update; leaving it unchanged"
282
+ )
283
+ continue
284
+ patched_page = patched_pages_dir / page_path
285
+ patched_page.parent.mkdir(parents=True, exist_ok=True)
286
+ patched_page.write_text(patched, encoding="utf-8")
287
+ files_to_upload.append((page_path, patched_page))
288
+
208
289
  for item in html_dir.iterdir():
209
290
  if item.is_file():
291
+ if item.name.endswith(".html"):
292
+ continue # pages are patched above
210
293
  files_to_upload.append((str(item.name), item))
211
294
  elif item.is_dir() and item.name == "assets":
212
295
  for subitem in item.iterdir():
@@ -339,8 +422,6 @@ def patch_figure(
339
422
  manifest["total_size"] = sum(f.get("size", 0) for f in updated_files)
340
423
 
341
424
  # Upload manifest.json
342
- import tempfile
343
-
344
425
  manifest_content = json.dumps(manifest, indent=2)
345
426
 
346
427
  with tempfile.NamedTemporaryFile(