diff --git a/src/web/BUILD b/src/web/BUILD
index f7170db4a6e..31ead270c33 100644
--- a/src/web/BUILD
+++ b/src/web/BUILD
@@ -2,7 +2,7 @@
# Copyright (c) 2026, The OpenROAD Authors
load("@rules_cc//cc:cc_library.bzl", "cc_library")
-load("@rules_python//python:defs.bzl", "py_binary")
+load("@rules_python//python:defs.bzl", "py_binary", "py_test")
load("//bazel:tcl_encode_or.bzl", "tcl_encode")
load("//bazel:tcl_wrap_cc.bzl", "tcl_wrap_cc")
load("//test:regression.bzl", "messages_txt")
@@ -12,6 +12,14 @@ package(
features = ["layering_check"],
)
+# Read by //src/web/test:asset_list_parity_test, which holds the two build
+# systems' asset lists to each other, and by the test of the report bundler.
+exports_files([
+ "BUILD",
+ "CMakeLists.txt",
+ "src/embed_report_assets.py",
+])
+
py_binary(
name = "embed_report_assets",
srcs = ["src/embed_report_assets.py"],
@@ -24,83 +32,68 @@ py_binary(
main = "src/embed_web_assets.py",
)
+# The vendored libraries are generated files (see third-party/README.md); this
+# is what catches one being edited in place instead of through the script.
+py_test(
+ name = "vendor_lock_test",
+ srcs = ["third-party/update_vendor.py"],
+ args = ["--check"],
+ data = glob(
+ ["third-party/**"],
+ exclude = ["third-party/update_vendor.py"],
+ ),
+ legacy_create_init = 0,
+ main = "third-party/update_vendor.py",
+)
+
+# The report inlines these into one
+
+
+
-
-
-
+
+
+
-
-
+
+
+
+
+
+
diff --git a/src/web/src/main.js b/src/web/src/main.js
index 825e4816847..9496151fbb0 100644
--- a/src/web/src/main.js
+++ b/src/web/src/main.js
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026, The OpenROAD Authors
-import { GoldenLayout, LayoutConfig } from 'https://esm.sh/golden-layout@2.6.0';
+// Resolved by the import map in index.html to the vendored copy under
+// third-party/, which the OpenROAD binary serves (issue #11065).
+import { GoldenLayout, LayoutConfig } from 'golden-layout';
import { latLngToDbu, dbuToLatLng, dbuRectToBounds } from './coordinates.js';
import { WebSocketManager } from './websocket-manager.js';
import {
diff --git a/src/web/src/web.cpp b/src/web/src/web.cpp
index da059f9ed99..e36cd27fb08 100644
--- a/src/web/src/web.cpp
+++ b/src/web/src/web.cpp
@@ -15,6 +15,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -42,6 +43,7 @@
#include "boost/json/value.hpp"
#include "clock_tree_report.h"
#include "color.h"
+#include "css_inliner.h"
#include "gui/heatMap.h"
#include "hierarchy_report.h"
#include "odb/db.h"
@@ -49,6 +51,7 @@
#include "odb/dbChipCallBackObj.h"
#include "request_dispatcher.h"
#include "request_handler.h"
+#include "sta/StringUtil.hh"
#include "tcl.h"
#include "tile_generator.h"
#include "timing_report.h"
@@ -1374,25 +1377,169 @@ WebServer::~WebServer()
extern const std::string_view kReportCSS;
extern const std::string_view kReportJS;
-static std::string base64Encode(const std::vector& data)
+static std::string base64Encode(const std::string_view data)
{
static const char kChars[]
= "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ const size_t size = data.size();
+ const auto byte = [data](const size_t i) {
+ return static_cast(static_cast(data[i]));
+ };
std::string result;
- result.reserve((data.size() + 2) / 3 * 4);
- for (size_t i = 0; i < data.size(); i += 3) {
- const unsigned b0 = data[i];
- const unsigned b1 = (i + 1 < data.size()) ? data[i + 1] : 0;
- const unsigned b2 = (i + 2 < data.size()) ? data[i + 2] : 0;
+ result.reserve((size + 2) / 3 * 4);
+ for (size_t i = 0; i < size; i += 3) {
+ const unsigned b0 = byte(i);
+ const unsigned b1 = (i + 1 < size) ? byte(i + 1) : 0;
+ const unsigned b2 = (i + 2 < size) ? byte(i + 2) : 0;
result += kChars[b0 >> 2];
result += kChars[((b0 & 3) << 4) | (b1 >> 4)];
- result
- += (i + 1 < data.size()) ? kChars[((b1 & 0xF) << 2) | (b2 >> 6)] : '=';
- result += (i + 2 < data.size()) ? kChars[b2 & 0x3F] : '=';
+ result += (i + 1 < size) ? kChars[((b1 & 0xF) << 2) | (b2 >> 6)] : '=';
+ result += (i + 2 < size) ? kChars[b2 & 0x3F] : '=';
+ }
+ return result;
+}
+
+static std::string base64Encode(const std::vector& data)
+{
+ return base64Encode(std::string_view(
+ reinterpret_cast(data.data()), data.size()));
+}
+
+// ── Inlining the vendored libraries into the saved report ──
+// The report is one file, opened with no server behind it, so every asset the
+// viewer loads is inlined here as a data: URI (issue #11065).
+
+std::string resolveAssetPath(const std::string_view base_dir,
+ const std::string_view reference)
+{
+ // generic_string(), not string(): the result is a key into the asset table,
+ // whose paths are always '/'-separated whatever the host uses.
+ return (std::filesystem::path(base_dir) / std::filesystem::path(reference))
+ .lexically_normal()
+ .generic_string();
+}
+
+const EmbeddedAsset* ReportAssets::find(const std::string_view path)
+{
+ const EmbeddedAsset* asset = findEmbeddedAsset(path);
+ if (!asset) {
+ // A miss is a binary built with the wrong asset list, not bad input.
+ logger_->warn(utl::WEB, 77, "Missing embedded asset {}.", path);
+ missing_ = true;
+ }
+ return asset;
+}
+
+// data: URI for an embedded asset, for use as a src or href in the report.
+static std::string assetDataUri(const std::string_view path,
+ ReportAssets& assets)
+{
+ const EmbeddedAsset* asset = assets.find(path);
+ if (!asset) {
+ return "";
+ }
+ return std::string("data:") + asset->content_type + ";base64,"
+ + base64Encode(asset->content());
+}
+
+size_t findUrlToken(const std::string_view css, const size_t from)
+{
+ // Driven off the '(' so the scan is a memchr and not a byte loop.
+ for (size_t paren = from;
+ (paren = css.find('(', paren)) != std::string_view::npos;
+ ++paren) {
+ if (paren < 3) {
+ continue;
+ }
+ const size_t at = paren - 3;
+ if (at < from || !sta::stringBeginEqual(css.substr(at), "url")) {
+ continue;
+ }
+ const char before = at > 0 ? css[at - 1] : ' ';
+ if (std::isalnum(static_cast(before)) == 0 && before != '_'
+ && before != '-') {
+ return at;
+ }
+ }
+ return std::string_view::npos;
+}
+
+std::string inlineStylesheetUrls(const std::string_view css,
+ const std::string_view base_dir,
+ ReportAssets& assets)
+{
+ std::string result;
+ size_t pos = 0;
+ while (true) {
+ const size_t open = findUrlToken(css, pos);
+ if (open == std::string_view::npos) {
+ break;
+ }
+ // A quoted reference may hold a parenthesis, so its closing quote bounds
+ // the token; an unquoted one ends at the ')'.
+ const size_t first = css.find_first_not_of(" \t\r\n", open + 4);
+ if (first == std::string_view::npos) {
+ break;
+ }
+ size_t close = std::string_view::npos;
+ std::string_view reference;
+ if (css[first] == '"' || css[first] == '\'') {
+ const size_t quote = css.find(css[first], first + 1);
+ if (quote == std::string_view::npos) {
+ break;
+ }
+ close = css.find(')', quote + 1);
+ reference = css.substr(first + 1, quote - first - 1);
+ } else {
+ close = css.find(')', first);
+ if (close == std::string_view::npos) {
+ break;
+ }
+ reference = css.substr(first, close - first);
+ while (!reference.empty()
+ && std::isspace(static_cast(reference.back()))
+ != 0) {
+ reference.remove_suffix(1);
+ }
+ }
+ if (close == std::string_view::npos) {
+ break;
+ }
+
+ // Fragment-only references (url(#default#VML)) and anything already
+ // inlined are left alone.
+ if (reference.empty() || reference.front() == '#'
+ || reference.starts_with("data:")) {
+ result += css.substr(pos, close + 1 - pos);
+ pos = close + 1;
+ continue;
+ }
+
+ result += css.substr(pos, open - pos);
+ result += "url(\"";
+ result += assetDataUri(resolveAssetPath(base_dir, reference), assets);
+ result += "\")";
+ pos = close + 1;
}
+ result += css.substr(pos);
return result;
}
+// data: URI for an embedded stylesheet, with its own references inlined
+// against the directory it is served from.
+static std::string stylesheetDataUri(const std::string_view path,
+ ReportAssets& assets)
+{
+ const EmbeddedAsset* asset = assets.find(path);
+ if (!asset) {
+ return "";
+ }
+ const std::string_view base_dir = path.substr(0, path.rfind('/') + 1);
+ return "data:text/css;base64,"
+ + base64Encode(
+ inlineStylesheetUrls(asset->content(), base_dir, assets));
+}
+
void WebServer::saveReport(const std::string& filename,
const int max_setup,
const int max_hold)
@@ -1414,6 +1561,7 @@ void WebServer::saveReport(const std::string& filename,
logger_->error(utl::WEB, 31, "Cannot open file: {}", filename);
return;
}
+ ReportAssets assets(logger_);
// ── Serialize JSON cache responses ──
@@ -1530,20 +1678,44 @@ void WebServer::saveReport(const std::string& filename,
// ── Write the HTML ──
- // HTML head — same CDN deps as index.html.
+ // HTML head — leaflet, golden-layout and three, inlined as data: URIs so the
+ // file opens with no server and no network. elk and netlistsvg are left out:
+ // they are 2.8 MB for a schematic panel that needs the server anyway, and the
+ // widget already stands down when it does not find them. The stylesheets
+ // stay elements rather than
@@ -1625,9 +1797,20 @@ window.__STATIC_CACHE__ = {
}
};
+
@@ -1636,6 +1819,20 @@ import * as THREE from 'https://esm.sh/three@0.160.0';
)";
out.close();
+
+ if (assets.missing()) {
+ // The warnings above name what was missed; no one is told this was saved.
+ // The error below is the one that has to come out, so a removal that fails
+ // must not throw over it.
+ std::error_code remove_error;
+ std::filesystem::remove(filename, remove_error);
+ logger_->error(utl::WEB,
+ 78,
+ "Not saving {}: the binary was built with an incomplete "
+ "asset list.",
+ filename);
+ return;
+ }
logger_->info(utl::WEB, 32, "Saved timing report to {}", filename);
}
diff --git a/src/web/src/web_assets.h b/src/web/src/web_assets.h
index f758b796d0f..cfd28f2512c 100644
--- a/src/web/src/web_assets.h
+++ b/src/web/src/web_assets.h
@@ -17,8 +17,19 @@ struct EmbeddedAsset
std::string_view content() const { return {data, size}; }
};
+struct EmbeddedAssetEntry
+{
+ std::string_view path;
+ EmbeddedAsset asset;
+};
+
// Returns the embedded asset for the given URL path (e.g. "/index.html"),
// or nullptr if not found.
const EmbeddedAsset* findEmbeddedAsset(std::string_view path);
+// Iteration over the whole asset table, for the test that holds every asset to
+// the rule that none of them loads anything remote.
+size_t embeddedAssetCount();
+const EmbeddedAssetEntry& embeddedAssetAt(size_t index);
+
} // namespace web
diff --git a/src/web/test/BUILD b/src/web/test/BUILD
index f4ae069badc..2c9b8d4335b 100644
--- a/src/web/test/BUILD
+++ b/src/web/test/BUILD
@@ -4,6 +4,7 @@ load("@npm//:defs.bzl", "npm_link_all_packages")
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2022-2026, The OpenROAD Authors
load("@rules_cc//cc:cc_test.bzl", "cc_test")
+load("@rules_python//python:defs.bzl", "py_test")
load("//test:regression.bzl", "regression_test")
package(features = ["layering_check"])
@@ -36,6 +37,26 @@ filegroup(
data = [":test_resources"],
) for test_name in TESTS]
+# The report bundler strips module syntax with regexes; this pins the shapes.
+py_test(
+ name = "embed_report_assets_test",
+ srcs = ["embed_report_assets_test.py"],
+ data = ["//src/web:src/embed_report_assets.py"],
+ legacy_create_init = 0,
+)
+
+# The asset lists live once per build system; this is what keeps them equal.
+py_test(
+ name = "asset_list_parity_test",
+ srcs = ["asset_list_parity.py"],
+ data = [
+ "//src/web:BUILD",
+ "//src/web:CMakeLists.txt",
+ ],
+ legacy_create_init = 0,
+ main = "asset_list_parity.py",
+)
+
JS_FILES = ["//src/web:js_files"]
DOM_TEST_DATA = [
@@ -46,10 +67,35 @@ DOM_TEST_DATA = [
test_suite(
name = "cpp_tests",
tests = [
+ ":css_inliner_test",
":gif_test",
":request_handler_test",
":save_display_controls_test",
":tile_generator_test",
+ ":web_assets_test",
+ ],
+)
+
+cc_test(
+ name = "css_inliner_test",
+ srcs = ["cpp/TestCssInliner.cpp"],
+ deps = [
+ "//src/gui:gui_stub",
+ "//src/utl",
+ "//src/web",
+ "@googletest//:gtest",
+ "@googletest//:gtest_main",
+ ],
+)
+
+cc_test(
+ name = "web_assets_test",
+ srcs = ["cpp/TestWebAssets.cpp"],
+ deps = [
+ "//src/gui:gui_stub",
+ "//src/web",
+ "@googletest//:gtest",
+ "@googletest//:gtest_main",
],
)
@@ -480,6 +526,7 @@ cc_test(
"//src/gui:gui_stub",
"//src/odb/src/db",
"//src/tst:nangate45_fixture",
+ "//src/utl",
"//src/web",
"@boost.json",
"@googletest//:gtest",
diff --git a/src/web/test/CMakeLists.txt b/src/web/test/CMakeLists.txt
index f2a3b850a9a..48338197be7 100644
--- a/src/web/test/CMakeLists.txt
+++ b/src/web/test/CMakeLists.txt
@@ -7,4 +7,23 @@ or_integration_tests(
cpp_tests
)
+# The vendored libraries under src/web/third-party are generated files; this
+# catches one being edited in place instead of through the script (issue #11065).
+add_test(
+ NAME web_vendor_lock
+ COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/../third-party/update_vendor.py --check
+)
+
+# The report bundler strips module syntax with regexes; this pins the shapes.
+add_test(
+ NAME web_embed_report_assets
+ COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/embed_report_assets_test.py
+)
+
+# The asset lists exist once per build system; this keeps them equal.
+add_test(
+ NAME web_asset_list_parity
+ COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/asset_list_parity.py
+)
+
add_subdirectory(cpp)
diff --git a/src/web/test/asset_list_parity.py b/src/web/test/asset_list_parity.py
new file mode 100755
index 00000000000..229598d9cdd
--- /dev/null
+++ b/src/web/test/asset_list_parity.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026, The OpenROAD Authors
+#
+# The web assets are listed once for Bazel and once for CMake, and nothing else
+# ties the two together: a file forgotten in one 404s under that build system,
+# and the report's list is ordered, since its files share one scope. Both have
+# drifted once already. This test is the tie.
+
+import os
+import re
+import sys
+
+WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
+
+
+def read(name):
+ with open(os.path.join(WEB_DIR, name), encoding="utf-8") as f:
+ return f.read()
+
+
+def bazel_list(build, name):
+ """The paths in a `name = [ ... ]` list assignment."""
+ body = re.search(rf"^{name} = \[(.*?)^\]", build, re.M | re.S).group(1)
+ return re.findall(r'"([^"]+)"', body)
+
+
+def cmake_list(cmakelists, name):
+ """The paths in a `set(NAME ...)` block, comments dropped."""
+ body = re.search(rf"^set\({name}\n(.*?)^\)", cmakelists, re.M | re.S).group(1)
+ return [
+ line.strip()
+ for line in body.splitlines()
+ if line.strip() and not line.strip().startswith("#")
+ ]
+
+
+def cmake_command_args(cmakelists, after):
+ """The src/ paths passed on a command line, in order."""
+ body = cmakelists[cmakelists.index(after) :]
+ body = body[: body.index("DEPENDS")]
+ return re.findall(r"\$\{CMAKE_CURRENT_SOURCE_DIR\}/(\S+)", body)
+
+
+def main():
+ build = read("BUILD")
+ cmakelists = read("CMakeLists.txt")
+
+ problems = []
+
+ # The embedded assets: same set, since the served path is derived from the
+ # file path the same way on both sides.
+ bazel_assets = set(bazel_list(build, "_WEB_ASSET_FILES"))
+ cmake_assets = set(cmake_list(cmakelists, "WEB_ASSET_FILES"))
+ for missing in sorted(bazel_assets - cmake_assets):
+ problems.append(f"embedded in BUILD but not in CMakeLists.txt: {missing}")
+ for missing in sorted(cmake_assets - bazel_assets):
+ problems.append(f"embedded in CMakeLists.txt but not in BUILD: {missing}")
+
+ # The report JS: same order, not just the same set.
+ bazel_js = bazel_list(build, "_REPORT_JS_FILES")
+ cmake_js = [
+ path
+ for path in cmake_command_args(cmakelists, "embed_report_assets.py")
+ if path != "src/style.css" and path != "src/embed_report_assets.py"
+ ]
+ if bazel_js != cmake_js:
+ problems.append(
+ "the report JS lists differ (order matters -- the files share one "
+ f"scope):\n BUILD: {bazel_js}\n CMake: {cmake_js}"
+ )
+
+ if problems:
+ print("\n".join(problems), file=sys.stderr)
+ return 1
+
+ print(
+ f"{len(bazel_assets)} embedded assets and {len(bazel_js)} report JS files "
+ "match between BUILD and CMakeLists.txt"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/web/test/cpp/CMakeLists.txt b/src/web/test/cpp/CMakeLists.txt
index 7d31c577ef6..801ca457ac7 100644
--- a/src/web/test/cpp/CMakeLists.txt
+++ b/src/web/test/cpp/CMakeLists.txt
@@ -210,6 +210,33 @@ gtest_discover_tests(TestGif
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..
)
+add_executable(TestWebAssets TestWebAssets.cpp)
+
+target_link_libraries(TestWebAssets ${TEST_LIBS})
+
+target_include_directories(TestWebAssets
+ PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}/../../src
+)
+
+gtest_discover_tests(TestWebAssets
+ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..
+)
+
+add_executable(TestCssInliner TestCssInliner.cpp ${GUI_STUB_SRCS})
+
+target_link_libraries(TestCssInliner ${TEST_LIBS})
+
+target_include_directories(TestCssInliner
+ PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}/../../src
+ ${GUI_STUB_INCLUDES}
+)
+
+gtest_discover_tests(TestCssInliner
+ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..
+)
+
add_dependencies(build_and_test
TestTileGenerator
TestRequestHandler
@@ -224,4 +251,6 @@ add_dependencies(build_and_test
TestLabels
TestSaveDisplayControls
TestGif
+ TestWebAssets
+ TestCssInliner
)
diff --git a/src/web/test/cpp/TestCssInliner.cpp b/src/web/test/cpp/TestCssInliner.cpp
new file mode 100644
index 00000000000..060cfa44e6d
--- /dev/null
+++ b/src/web/test/cpp/TestCssInliner.cpp
@@ -0,0 +1,136 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// Copyright (c) 2026, The OpenROAD Authors
+//
+// The saved report rewrites the url() references in the stylesheets it inlines
+// (issue #11065). One the scanner misses ships a broken report; one it invents
+// refuses a good one. Both edges are pinned here, against the real icons.
+
+#include
+#include
+#include
+
+#include "css_inliner.h"
+#include "gtest/gtest.h"
+#include "utl/Logger.h"
+
+namespace web {
+namespace {
+
+constexpr std::string_view kLeafletDir = "/third-party/leaflet/";
+constexpr std::string_view kLayers = "images/layers.png";
+
+bool contains(const std::string& haystack, const std::string_view needle)
+{
+ return haystack.find(needle) != std::string::npos;
+}
+
+// ─── findUrlToken ───────────────────────────────────────────────────────────
+
+TEST(CssInliner, UrlTokenIsCaseInsensitive)
+{
+ // A dependency bump that emits URL( must not slip a reference through.
+ for (const char* css : {"a{background:url(x.png)}",
+ "a{background:URL(x.png)}",
+ "a{background:Url(x.png)}",
+ "a{background:uRl(x.png)}"}) {
+ EXPECT_EQ(findUrlToken(css, 0), 13u) << css;
+ }
+}
+
+TEST(CssInliner, UrlTokenHasToBeAToken)
+{
+ // The tail of an identifier is not a reference.
+ EXPECT_EQ(findUrlToken("a{content:myurl(x.png)}", 0), std::string_view::npos);
+ EXPECT_EQ(findUrlToken("a{content:foo-url(x.png)}", 0),
+ std::string_view::npos);
+ EXPECT_EQ(findUrlToken("a{content:x_url(x.png)}", 0), std::string_view::npos);
+
+ // A boundary is anything that cannot continue an identifier.
+ EXPECT_EQ(findUrlToken("url(x.png)", 0), 0u);
+ EXPECT_EQ(findUrlToken("a{background:url(x.png)}", 0), 13u);
+ EXPECT_EQ(findUrlToken("@import url(x.css);", 0), 8u);
+}
+
+TEST(CssInliner, UrlTokenStartsAtFrom)
+{
+ const std::string_view css = "a{b:url(1.png);c:url(2.png)}";
+ const size_t first = findUrlToken(css, 0);
+ ASSERT_EQ(first, 4u);
+ EXPECT_EQ(findUrlToken(css, first + 1), 17u);
+ EXPECT_EQ(findUrlToken(css, 18), std::string_view::npos);
+}
+
+// ─── resolveAssetPath ───────────────────────────────────────────────────────
+
+TEST(CssInliner, ResolvesAgainstTheServedDirectory)
+{
+ EXPECT_EQ(resolveAssetPath(kLeafletDir, kLayers),
+ "/third-party/leaflet/images/layers.png");
+ EXPECT_EQ(resolveAssetPath("/third-party/golden-layout/css/themes/",
+ "../../img/lm_close_white.png"),
+ "/third-party/golden-layout/img/lm_close_white.png");
+ EXPECT_EQ(resolveAssetPath(kLeafletDir, "./images/./layers.png"),
+ "/third-party/leaflet/images/layers.png");
+ // A reference that starts at the root replaces the directory.
+ EXPECT_EQ(resolveAssetPath(kLeafletDir, "/three/x.js"), "/three/x.js");
+}
+
+// ─── inlineStylesheetUrls ───────────────────────────────────────────────────
+
+TEST(CssInliner, InlinesEveryReferenceForm)
+{
+ utl::Logger logger;
+ const std::string inlined = "url(\"data:image/png;base64,";
+
+ for (const char* css : {"a{background:url(images/layers.png)}",
+ "a{background:url( images/layers.png )}",
+ "a{background:url('images/layers.png')}",
+ "a{background:url(\"images/layers.png\")}",
+ "a{background:URL(images/layers.png) no-repeat}",
+ "a{background:url(/third-party/leaflet/"
+ "images/layers.png)}"}) {
+ ReportAssets assets(&logger);
+ const std::string out = inlineStylesheetUrls(css, kLeafletDir, assets);
+ EXPECT_TRUE(contains(out, inlined)) << css << " -> " << out;
+ EXPECT_FALSE(assets.missing()) << css;
+ }
+}
+
+TEST(CssInliner, LeavesAloneWhatIsNotAReference)
+{
+ utl::Logger logger;
+ ReportAssets assets(&logger);
+
+ // leaflet.css really carries the first of these.
+ for (const char* css : {"a{behavior:url(#default#VML)}",
+ "a{background:url(\"data:image/png;base64,AA\")}",
+ "a{content:myurl(images/layers.png)}"}) {
+ EXPECT_EQ(inlineStylesheetUrls(css, kLeafletDir, assets), css) << css;
+ }
+ EXPECT_FALSE(assets.missing());
+}
+
+TEST(CssInliner, AQuotedReferenceMayHoldAParenthesis)
+{
+ utl::Logger logger;
+ ReportAssets assets(&logger);
+
+ // The token ends at the quote, not the first ')', which would cut the
+ // stylesheet in half. No such icon exists, so it resolves to a miss.
+ const std::string out = inlineStylesheetUrls(
+ "a{background:url(\"a(b).png\")}!", kLeafletDir, assets);
+ EXPECT_EQ(out, "a{background:url(\"\")}!");
+ EXPECT_TRUE(assets.missing());
+}
+
+TEST(CssInliner, MissingReferenceIsReported)
+{
+ utl::Logger logger;
+ ReportAssets assets(&logger);
+
+ inlineStylesheetUrls("a{background:url(nope.png)}", kLeafletDir, assets);
+ EXPECT_TRUE(assets.missing());
+}
+
+} // namespace
+} // namespace web
diff --git a/src/web/test/cpp/TestSaveReport.cpp b/src/web/test/cpp/TestSaveReport.cpp
index bb3c615fca4..e5a5b2aebe3 100644
--- a/src/web/test/cpp/TestSaveReport.cpp
+++ b/src/web/test/cpp/TestSaveReport.cpp
@@ -3,26 +3,45 @@
#include
+#include
#include
#include
#include
#include
#include
+#include
#include
#include
#include "boost/json/serialize.hpp"
+#include "css_inliner.h"
#include "gtest/gtest.h"
#include "odb/db.h"
#include "odb/dbTypes.h"
#include "tile_generator.h"
#include "timing_report.h"
#include "tst/nangate45_fixture.h"
+#include "utl/decode.h"
#include "web/web.h"
namespace web {
namespace {
+// Does the line start with this keyword, as a keyword? "importantly" and
+// "exports" are identifiers, not module syntax.
+bool isKeyword(const std::string_view line, const std::string_view keyword)
+{
+ if (!line.starts_with(keyword)) {
+ return false;
+ }
+ if (line.size() == keyword.size()) {
+ return true;
+ }
+ const char next = line[keyword.size()];
+ return std::isalnum(static_cast(next)) == 0 && next != '_'
+ && next != '$';
+}
+
// ─── Fixture ────────────────────────────────────────────────────────────────
class SaveReportTest : public tst::Nangate45Fixture
@@ -134,8 +153,12 @@ TEST_F(SaveReportTest, ContainsRequiredHTMLElements)
EXPECT_TRUE(contains(html, "id=\"gl-container\""));
EXPECT_TRUE(contains(html, "id=\"menu-bar\""));
EXPECT_TRUE(contains(html, "id=\"loading-overlay\""));
- EXPECT_TRUE(contains(html, "leaflet.css"));
- EXPECT_TRUE(contains(html, "goldenlayout-base.css"));
+ // The stylesheets are inlined, so their file names are gone; what has to
+ // survive is the element, which theme.js toggles by id.
+ EXPECT_TRUE(
+ contains(html, ", with their import/export
+// statements stripped: one that survives costs every widget in the report.
+TEST_F(SaveReportTest, InlinedScriptHasNoModuleSyntaxLeft)
{
- const std::string path = tempHtml("gl_cdn");
+ const std::string path = tempHtml("module_syntax");
generateReport(path);
const std::string html = readFile(path);
- // GoldenLayout loaded via ES module import from CDN.
+ const std::string opening = "", begin);
+ ASSERT_NE(end, std::string::npos);
+ const std::string module
+ = html.substr(begin + opening.size(), end - begin - opening.size());
+
+ // The generator marks every source, so the first marker is the boundary:
+ // before it the header's imports, after it code with no module syntax.
+ const size_t body = module.find("// ── ");
+ ASSERT_NE(body, std::string::npos);
+
+ int header_imports = 0;
+ for (size_t at = 0; at < module.size();) {
+ const size_t eol = std::min(module.find('\n', at), module.size());
+ std::string_view line(module.data() + at, eol - at);
+ const bool in_header = at < body;
+ at = eol + 1;
+
+ const size_t indent = line.find_first_not_of(" \t");
+ if (indent == std::string_view::npos) {
+ continue;
+ }
+ line.remove_prefix(indent);
+ if (!isKeyword(line, "import") && !isKeyword(line, "export")) {
+ continue;
+ }
+ if (in_header && isKeyword(line, "import")) {
+ ++header_imports;
+ continue;
+ }
+ ADD_FAILURE() << "leftover module syntax: " << line;
+ }
+ // GoldenLayout and THREE, however they are resolved.
+ EXPECT_EQ(header_imports, 2);
+}
+
+// The report opens from file:// with nothing behind it, so nothing in it may
+// point at a remote host (issue #11065).
+TEST_F(SaveReportTest, IsSelfContained)
+{
+ const std::string path = tempHtml("self_contained");
+ generateReport(path);
+ const std::string html = readFile(path);
+
+ // No attribute, url() or specifier may name a remote origin. Bare "http://"
+ // is left alone: the widgets carry XML namespaces, which are identifiers.
+ for (const char* fetch : {"src=\"http",
+ "src='http",
+ "href=\"http",
+ "href='http",
+ "url(http",
+ "url(\"http",
+ "url('http",
+ "from 'http",
+ "from \"http"}) {
+ EXPECT_FALSE(contains(html, fetch)) << fetch;
+ }
+ // leaflet as a classic script, three and golden-layout as ES modules the
+ // import map redirects to their inlined copies.
+ EXPECT_TRUE(
+ contains(html, "
-
+
+