diff --git a/code/account1.json b/code/account1.json new file mode 100644 index 00000000..13f332d8 --- /dev/null +++ b/code/account1.json @@ -0,0 +1,13 @@ +[ + { + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJteWZFenA3ODNLaV9KQ3g4Vm5jM1hfaXg2alpyYjZDZjVPTWtHWk1QSTNzIn0.eyJleHAiOjE3OTQ3MjUzMzksImlhdCI6MTc4OTU0MTMzOSwianRpIjoiODljMWFjZjYtMzcxNS00ZjEyLThhZWItMzljMGQ1MTNiNGRjIiwiaXNzIjoiaHR0cHM6Ly9jb3BpbG90LnRlbmNlbnQuY29tL2F1dGgvcmVhbG1zL2NvcGlsb3QiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiNmJiZDQxMTUtNWJjOS00YmE3LTg1NDQtMzdlODcyMTUxZTBmIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiY29uc29sZSIsInNpZCI6IjhiNGY0NjlmLTc5NDMtNGYyMC1hMjVjLTE3YTQzY2U3NWM0OCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiKiJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcyIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6InByb2ZpbGUgb2ZmbGluZV9hY2Nlc3MgZW1haWwiLCJhcHBfdHlwZSI6ImNvZGVidWRkeSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiNjUxNjcxMDAiLCJlbnRlcnByaXNlX2lkIjoiIiwidG9rZW5fc291cmNlIjoiZW50ZXJwcmlzZV9zd2l0Y2gifQ.bbQJOKLg9CFON7HzkXLElnZDMjf1l0vRdeRdViC7KL7D-1OKaTcTL5M0QHJoGbOgtZERlVGmPoj7nviJ1QdXcbMVXuR0g9xL8_zz1KqzgMAAs1fmTnwFy--vPV7k_LlqK6kjGz5gbTnFNEXUIETmczSvi4NWoDQ2W33NgQVxmQaikvmhz8Snq0f3Ke-gYpJrW2MLytuG4f2hC6TLgbcpgGlxzZh-RiBE6zUT_UazCZm35Pq63yJVIjn7mLNvS8zTvU3HlXP_pxJlQXiDwEwFqKaWTNnD_h4pYIuWe81f5TizM_KxSudcmHRA1Kv-mOQlDGWLKyqLv-9zUyPnM8-ybA", + "domain": "www.codebuddy.cn", + "email": "65167100", + "expires_at": 1794725339, + "nickname": "65167100", + "phone": "65167100", + "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2M2I4YzRkNS1jMTJjLTRhMGQtYjk5NC01ZTBjMDY0N2QwMDIifQ.eyJleHAiOjE3OTU1ODkzMzksImlhdCI6MTc4OTU0MTMzOSwianRpIjoiZDE3Y2ZiNDgtNmMxOC00MzBlLTk5NzMtZDQzNzZhZGE3N2U1IiwiaXNzIjoiaHR0cHM6Ly9jb3BpbG90LnRlbmNlbnQuY29tL2F1dGgvcmVhbG1zL2NvcGlsb3QiLCJhdWQiOiJodHRwczovL2NvcGlsb3QudGVuY2VudC5jb20vYXV0aC9yZWFsbXMvY29waWxvdCIsInN1YiI6IjZiYmQ0MTE1LTViYzktNGJhNy04NTQ0LTM3ZTg3MjE1MWUwZiIsInR5cCI6Ik9mZmxpbmUiLCJhenAiOiJjb25zb2xlIiwic2lkIjoiOGI0ZjQ2OWYtNzk0My00ZjIwLWEyNWMtMTdhNDNjZTc1YzQ4Iiwic2NvcGUiOiJhY3IgcHJvZmlsZSBiYXNpYyB3ZWItb3JpZ2lucyByb2xlcyBvZmZsaW5lX2FjY2VzcyBlbWFpbCJ9.ir-XJ4kurVBXnfImAzXJBF40ZeSWvuiOKRR8bPgH0kX5ePtR4qodPfwWcLrET6qhrY2MXx-XdBHgJFc1LiIk4w", + "token_type": "Bearer", + "uid": "6bbd4115-5bc9-4ba7-8544-37e872151e0f" + } +] \ No newline at end of file diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class index ba7577c4..16e04d81 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/FmsApiApplicationTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class index b68ad01c..3818e35d 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class index 50c335c9..6f18a08a 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceFactoryTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class index e741e737..87d0c86e 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests$1.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class index 4b8e906a..cae9c258 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDataSourceManagerTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class index 8dd518fe..b771450e 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoaderTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class index 109918f7..0c37f74f 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests$1.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class index 27facbf7..f6a9a417 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcherTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class index 62a855e6..c4ed5132 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class index acc383b6..6310143e 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class index 622e41a8..e9bfccb4 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/AuthServiceTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class index f561651b..25c40ab4 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests$JdbcFixture.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class index c6ca5921..00725152 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/DataSaveServiceTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class index 42ba670b..b2ce9c11 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class index 67525ed0..47e20269 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class index f0ba2163..843a79c3 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class index 035c5217..b03b69d7 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class index e9485923..09ae1d75 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/JwtUtilsTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class index e777433d..ff9a76ed 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/utils/snowflake/DefaultIdGeneratorTests.class differ diff --git a/code/fms/fms-api/tools/migration/CheckMenuTree.class b/code/fms/fms-api/tools/migration/CheckMenuTree.class new file mode 100644 index 00000000..22efff6c Binary files /dev/null and b/code/fms/fms-api/tools/migration/CheckMenuTree.class differ diff --git a/code/fms/fms-api/tools/migration/CheckMenuTree.java b/code/fms/fms-api/tools/migration/CheckMenuTree.java new file mode 100644 index 00000000..4f2e8959 --- /dev/null +++ b/code/fms/fms-api/tools/migration/CheckMenuTree.java @@ -0,0 +1,50 @@ +import java.sql.*; + +/** 只读核对:s_menu 全量父子关系、路由、path,确认哪些节点挂错了父级。 */ +public class CheckMenuTree { + + public static void main(String[] args) throws Exception { + String url = "jdbc:sqlserver://118.89.70.199:1433;databaseName=FMS;encrypt=true;trustServerCertificate=true"; + try (Connection conn = DriverManager.getConnection(url, "sa", System.getenv("FMS_SA_PWD"))) { + + System.out.println("=== s_menu 全量(按现有 b_parent_id 展开)==="); + try (PreparedStatement ps = conn.prepareStatement( + "select b_id, b_name, b_parent_id, b_depth, b_path, b_route, b_menu_type, b_canuse, b_xh " + + "from s_menu order by b_depth, b_xh, b_id"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + System.out.printf(" id=%-12s name=%-10s parent=%-10s depth=%-2d canuse=%d%n", + rs.getString("b_id"), rs.getString("b_name"), + String.valueOf(rs.getString("b_parent_id")), + rs.getInt("b_depth"), rs.getInt("b_canuse")); + System.out.printf(" path=%-28s route=%-26s type=%s%n", + rs.getString("b_path"), String.valueOf(rs.getString("b_route")), + rs.getString("b_menu_type")); + } + } + + System.out.println("\n=== 路由前缀 vs 父级 一致性检查 ==="); + try (PreparedStatement ps = conn.prepareStatement( + "select m.b_id, m.b_name, m.b_parent_id, m.b_route, m.b_path, " + + "p.b_name as parent_name, p.b_path as parent_path " + + "from s_menu m left join s_menu p on p.b_id = m.b_parent_id " + + "where m.b_route is not null order by m.b_id"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String route = String.valueOf(rs.getString("b_route")); + String parentPath = String.valueOf(rs.getString("parent_path")); + // 路由首段(/base/othercompany -> base)与父级 path 段比对 + String routeSeg = route.split("/").length > 1 ? route.split("/")[1] : ""; + String parentSeg = parentPath.split("/").length > 1 ? parentPath.split("/")[1] : ""; + boolean mismatch = !routeSeg.isEmpty() && !parentSeg.isEmpty() + && !routeSeg.equals(parentSeg); + System.out.printf(" %-12s route=%-24s 父=%-10s(父path=%-16s) %s%n", + rs.getString("b_id"), route, + String.valueOf(rs.getString("parent_name")), + parentPath, + mismatch ? " <== 不一致" : ""); + } + } + } + } +} diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue index 361bd3b4..fb04594e 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue @@ -66,6 +66,9 @@ const props = defineProps({ rowDraggable: { type: Boolean, default: false }, // 行拖拽排序字段;未显式传入时不启用业务序号排序。 orderField: { type: String, default: '' }, + // 临时放开的隐藏列(view JSON 里 visible:false 的字段):传入字段名即强制显示该列, + // 供「查看密码」这类按需揭示敏感列的页面开关使用。只影响本次渲染,不回写 view 配置。 + revealedColumns: { type: Array, default: () => [] }, }) const emit = defineEmits([ @@ -128,6 +131,78 @@ const columnDirty = ref(false) const columnPrefRowExists = ref(false) // 通用栏位行(s_module_schema.view 展开结果):个人增量与其比较,「保存为通用」也以它为底 const columnGlobalRows = shallowRef([]) +// view JSON 里 visible:false 的列(如 b_password):默认不渲染,由 revealedColumns 开关按需追加 +const hiddenColumnRows = shallowRef([]) +// 个人层栏位增量(s_user_module_pref):revealedColumns 开关重算列时复用同一份,避免丢个人宽度/顺序 +const savedColumnPref = shallowRef(null) + +/** + * 从隐藏列里挑出本次要揭示的行。 + * + * 关键:必须把 b_visible 改写成 1 再交出去。FmsModuleTable.buildColumns 会再过滤一次 + * `b_visible === 1`,原样透传 b_visible:0 会在那一层被丢掉,列永远出不来。 + * 返回新对象,不改 hiddenColumnRows 里的原行,保证关掉开关后仍是隐藏态。 + * + * @param orderedRows view JSON 展开后的完整行序(listRows),用于保持列的原声明顺序 + * @param revealed 要揭示的字段名 + */ +function revealedColumnsOf(orderedRows, revealed) { + const wanted = new Set((revealed || []).map((field) => String(field))) + if (!wanted.size) return [] + return (orderedRows || []) + .filter((row) => wanted.has(String(row.b_field))) + .map((row) => ({ ...row, b_visible: 1 })) +} + +/** + * 把揭示出来的列按 view JSON 的声明顺序插回可见列里。 + * + * 不能直接 [...visible, ...revealed] 追加到末尾:那样列位置由开关决定而不是配置决定, + * 配置里写在 b_id 之后的列会跑到最后一列。这里按 b_xh(声明序号)做一次归并。 + */ +function mergeRevealedColumns(visibleRows, revealedRows) { + if (!revealedRows.length) return visibleRows + const rank = (row) => { + const xh = Number(row?.b_xh) + // 无声明序号的排在最后,保持稳定 + return Number.isFinite(xh) ? xh : Number.MAX_SAFE_INTEGER + } + return [...visibleRows, ...revealedRows] + .map((row, index) => ({ row, index })) + .toSorted((a, b) => { + const ra = rank(a.row) + const rb = rank(b.row) + return ra === rb ? a.index - b.index : ra - rb + }) + .map((item) => item.row) +} + +/** + * 把个人层已保存的栏位顺序,重写成「含揭示列」的新顺序。 + * + * 个人层 order 是整体排列。用户早先保存过顺序时,列表里只含当时的可见列; + * 揭示列(如 b_password)不在其中,mergeColumnPref 会按「未列入的字段追加在后」 + * 把它排到最后一列 —— 上游插得再对也会被这一步覆盖。 + * 这里按揭示列的 b_xh 找到它该在的位置,把 order 补全后再交给 mergeColumnPref。 + * + * @param pref 个人层增量(含 order,可能为 null) + * @param visibleRows 可见列(通用顺序,含 b_xh) + * @param revealedRows 本次揭示的列(含 b_xh) + */ +function withRevealedInOrder(pref, visibleRows, revealedRows) { + if (!pref || !revealedRows.length) return pref + const orderList = Array.isArray(pref.order) ? pref.order.map(String) : [] + if (!orderList.length) return pref + + // 按 b_xh 归并:揭示列插到第一个声明序号比它大的可见列之前,与真实列序一致 + const sequence = mergeRevealedColumns(visibleRows, revealedRows).map((row) => + String(row.b_field), + ) + // 只保留确实存在于本次列集合里的字段,并保持个人层的相对顺序 + const kept = orderList.filter((field) => sequence.includes(field)) + const merged = sequence.filter((field) => kept.includes(field) || !orderList.includes(field)) + return { ...pref, order: merged } +} // 「保存为通用」会改到所有人的默认视图,限系统管理员(与 /module/* 的 meta.adminOnly 同一判据) const canSaveColumnGlobal = computed(() => permissions.isG3soft) // 无查询权限的字段(s_user_field_power.b_query = 0),设置抽屉中灰显 @@ -313,12 +388,23 @@ async function loadModuleConfig() { } editConfigById.value = editConfigByIdMap + // 通用栏位基线(lists)恒为「常规可见列」,揭示列不进基线: + // 否则开关重算时会与隐藏列副本重复拼接,出现两列表头。 const lists = [] + hiddenColumnRows.value = [] for (const item of listRows) { - if (Number(item.b_visible) !== 1) continue if (!fieldById.has(String(item.b_field))) continue + if (Number(item.b_visible) !== 1) { + // view 里显式隐藏的列(如 b_password):默认不渲染,只留副本供揭示开关取用 + hiddenColumnRows.value.push(item) + continue + } lists.push(item) } + // 已揭示的隐藏列:按 view JSON 声明的原始顺序插回,而不是一律追加到末尾; + // 基线仍是纯可见列,揭示列只参与本次渲染。 + const revealedLists = revealedColumnsOf(listRows, props.revealedColumns) + const renderLists = mergeRevealedColumns(lists, revealedLists) // 通用栏位(lists)即「保存为通用」的基线与个人增量的比较基准; // 个人层只叠加宽度与顺序,不增删字段,因此先裁到同一字段集再合并。 columnGlobalRows.value = lists @@ -347,7 +433,7 @@ async function loadModuleConfig() { // 两层配置(系统默认 → 个人)+ 字段查询权限;权限在偏好合并之后裁剪,不得放宽权限 const userId = currentUserId() // 查询布局(query)与栏位布局(view)各有一份个人层增量,互不影响 - const [savedPref, savedColumnPref] = await Promise.all([ + const [savedPref, savedColumnPrefDelta] = await Promise.all([ loadModulePref(userId, dataId), loadModulePref(userId, dataId, PREF_SCHEMA_TYPES.view), ]) @@ -374,9 +460,15 @@ async function loadModuleConfig() { queryLayout.value = mergeQueryLayout(defaultQueryLayout.value, savedPref, allowedFields) // 栏位:通用(lists)→ 个人层叠加宽度与顺序;个人层不增删字段(通用没有的字段一律忽略) - const mergedLists = mergeColumnPref(lists, savedColumnPref) - columnPrefRowExists.value = Boolean(savedColumnPref) - columnBaseline.value = columnRowsOf(mergedLists) + // 首次渲染带上已揭示的隐藏列(按声明顺序插回);基线只取通用可见列,避免把揭示列固化进栏位设置 + // 个人层 order 不含揭示列时会把列挤到末尾,先补进 order 再合并 + const mergedLists = mergeColumnPref( + renderLists, + withRevealedInOrder(savedColumnPrefDelta, lists, revealedLists), + ) + columnPrefRowExists.value = Boolean(savedColumnPrefDelta) + savedColumnPref.value = savedColumnPrefDelta + columnBaseline.value = columnRowsOf(mergeColumnPref(lists, savedColumnPrefDelta)) columnDirty.value = false // 默认条件取全部启用字段:简单查询与高级查询共用同一批启用字段 @@ -1260,6 +1352,22 @@ onActivated(async () => { watch(() => props.dataCode, initialize) +// 按需揭示的隐藏列(如「查看密码」):开关变化时不重新请求数据, +// 只在已有的通用栏位基线上重算可见列,避免整表刷新造成闪烁。 +watch( + () => props.revealedColumns, + () => { + if (!columnGlobalRows.value.length && !props.revealedColumns?.length) return + // 按 view JSON 声明顺序插回,与 loadModuleConfig 走同一套归并,避免两处顺序不一致 + const next = revealedColumnsOf(hiddenColumnRows.value, props.revealedColumns) + listConfig.value = mergeColumnPref( + mergeRevealedColumns(columnGlobalRows.value, next), + // 个人层 order 不含揭示列时会把列挤到末尾,先补进 order 再合并 + withRevealedInOrder(savedColumnPref.value, columnGlobalRows.value, next), + ) + }, +) + watch( () => props.fixedSearchCondition, () => { diff --git a/code/fms/fms-vue/src/views/system/user/UserPowerDrawer.vue b/code/fms/fms-vue/src/views/system/user/UserPowerDrawer.vue new file mode 100644 index 00000000..36f2a2c0 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/user/UserPowerDrawer.vue @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/code/fms/fms-vue/src/views/system/user/UserPowerPanel.vue b/code/fms/fms-vue/src/views/system/user/UserPowerPanel.vue new file mode 100644 index 00000000..128e6841 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/user/UserPowerPanel.vue @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + 请选择左侧菜单 + + 该菜单未绑定模块 + + + + + + + {{ group.moduleName }} + {{ group.checked }}/{{ group.powers.length }} + + + + + + {{ power.b_name || power.b_action }} + + + 该模块未配置权限点 + + + + + + + diff --git a/code/fms/fms-vue/src/views/system/user/index.vue b/code/fms/fms-vue/src/views/system/user/index.vue index 4292f2d1..5db84390 100644 --- a/code/fms/fms-vue/src/views/system/user/index.vue +++ b/code/fms/fms-vue/src/views/system/user/index.vue @@ -15,7 +15,7 @@ * 页面同时管两个领域,部门相关标识一律带 dept 前缀,避免与用户侧同名。 */ import { computed, ref, watch } from 'vue' -import { Pencil, Plus, Trash2 } from '@lucide/vue' +import { Eye, EyeOff, Pencil, Plus, Trash2 } from '@lucide/vue' import { Button, Form, @@ -29,6 +29,7 @@ import { import { FmsModuleListPage } from '@/components/fms-module-list' import { FmsModuleEditModal } from '@/components/fms-module-edit' import FmsTreePanel from '@/components/fms-tree/FmsTreePanel.vue' +import UserPowerDrawer from './UserPowerDrawer.vue' import { escapeSqlText } from '@/components/fms-module-common/queryUtils' import { loadDataApi, saveObjectApi } from '@/services/api' import { usePermissionStore } from '@/stores/permissions' @@ -429,6 +430,20 @@ async function onSaved() { await listRef.value?.reload() } +/* ================= 查看密码(按需揭示隐藏列) ================= */ +/** + * 密码列(b_password)在 v_b_user 的 view 配置里是 visible:false,默认不渲染。 + * 点「查看密码」把该字段加进 revealedColumns,FmsModuleListPage 会把它追加到列末尾; + * 再点一次清空即隐藏。纯前端显隐开关,不回写任何配置。 + */ +const passwordVisible = ref(false) + +const revealedColumns = computed(() => (passwordVisible.value ? ['b_password'] : [])) + +function togglePassword() { + passwordVisible.value = !passwordVisible.value +} + /* ================= 行多选与批量删除 ================= */ const selectedRowKeys = ref([]) @@ -439,10 +454,19 @@ const rowSelection = computed(() => ({ }, })) -/* ================= 行操作:编辑 / 删除 ================= */ +/* ================= 行操作:编辑 / 分配权限 / 删除 ================= */ /** 单行删除进行中:阻止行操作按钮重复触发 */ const rowSaving = ref(false) +/** 「分配权限」当前操作的账号,供权限抽屉读取 */ +const powerUser = ref(null) +const powerOpen = ref(false) + +function onAssignPower(row) { + powerUser.value = row + powerOpen.value = true +} + // 删除走 FmsTable 内置的 confirm 状态化弹窗(与 i18n-type / contact 一致), // 不再手工 Modal.confirm,少一层控制流。 const rowActions = computed(() => { @@ -453,6 +477,12 @@ const rowActions = computed(() => { disabled: () => rowSaving.value, onClick: onEdit, }, + { + key: 'power', + label: '分配权限', + disabled: () => rowSaving.value, + onClick: onAssignPower, + }, ] if (canDeleteRow.value) { actions.push({ @@ -541,7 +571,8 @@ void loadDepts() :fixed-search-condition="deptFilterCondition" :row-selection="rowSelection" :row-actions="rowActions" - :inline-action-limit="2" + :revealed-columns="revealedColumns" + :inline-action-limit="3" > 新增 + + + {{ passwordVisible ? '隐藏密码' : '查看密码' }} + + + + diff --git a/code/fms/fms-vue/tests/views/system-user.spec.js b/code/fms/fms-vue/tests/views/system-user.spec.js index 277bb05a..960ba77f 100644 --- a/code/fms/fms-vue/tests/views/system-user.spec.js +++ b/code/fms/fms-vue/tests/views/system-user.spec.js @@ -275,7 +275,20 @@ describe('用户管理:行操作', () => { const wrapper = await mountUser() const keys = wrapper.vm.rowActions.map((action) => action.key) - expect(keys).toEqual(['edit', 'delete']) + expect(keys).toEqual(['edit', 'power', 'delete']) + }) + + it('提供分配权限操作,点开时把该行交给权限抽屉', async () => { + const wrapper = await mountUser() + + const powerAction = wrapper.vm.rowActions.find((action) => action.key === 'power') + expect(powerAction).toBeTruthy() + + const row = { b_id: 'u1', b_name: '张三' } + powerAction.onClick(row) + + expect(wrapper.vm.powerUser).toStrictEqual(row) + expect(wrapper.vm.powerOpen).toBe(true) }) it('新增时把编辑对象清空,避免带入上一次编辑的行', async () => { diff --git a/code/fms/sql/fms_mock_power.sql b/code/fms/sql/fms_mock_power.sql new file mode 100644 index 00000000..78bfa26b --- /dev/null +++ b/code/fms/sql/fms_mock_power.sql @@ -0,0 +1,119 @@ +/* ============================================================================ + FMS 模拟数据脚本 — 用户功能权限(s_power / s_user_power) + + 用途:为「用户管理 → 分配权限 → 功能权限」抽屉准备可演示的数据。 + 现状(执行前):s_power 仅 2 行、s_user_power 0 行,权限抽屉打开是空的。 + + 本脚本做两件事: + 1. 为若干启用模块补齐标准权限点(s_power); + 2. 给演示账号 001 写入一批初始授权(s_user_power)。 + + 权限编码规则(见《FMS新系统核心表结构设计》§14.1,前端 utils.buildPowerCode 同一口径): + 模块使用权限 module.{模块编码} b_type='module' b_action='access' + 业务动作权限 action.{模块编码}.{动作} b_type='action' b_action=动作名 + + 可重复执行:先按 b_object_id 清理本脚本负责的模块,再重建。 + ============================================================================ */ + +/* ---------------------------------------------------------------------------- + 1. 清理本脚本负责的权限点(只动本脚本登记的模块,不影响 s_i18n_type 等既有行) + ---------------------------------------------------------------------------- */ + +delete from dbo.s_power +where b_object_type = 'module' + and b_object_id in ( + 'v_b_othercompany', 'v_b_contact', + 'b_money', 'b_feetype', 'b_port', 'b_oceanline', 'b_vessel', 'b_depot' + ); +GO + +/* ---------------------------------------------------------------------------- + 2. 模块使用权限 point:module.{模块编码} + + b_name 用模块名,前端展示时优先取 s_i18n,这里给中文兜底。 + b_i18n 用 module.{模块编码},与模块名称共用资源键(见设计文档 §11)。 + ---------------------------------------------------------------------------- */ + +insert into dbo.s_power (b_id, b_name, b_i18n, b_type, b_object_type, b_object_id, b_action, b_canuse, b_xh, b_bz) +values + ('module.v_b_othercompany', N'合作伙伴', 'module.v_b_othercompany', 'module', 'module', 'v_b_othercompany', 'access', 1, 10, N'模拟数据:模块使用权限'), + ('module.v_b_contact', N'联系人', 'module.v_b_contact', 'module', 'module', 'v_b_contact', 'access', 1, 20, N'模拟数据:模块使用权限'), + ('module.b_money', N'币种', 'module.b_money', 'module', 'module', 'b_money', 'access', 1, 30, N'模拟数据:模块使用权限'), + ('module.b_feetype', N'费用类别', 'module.b_feetype', 'module', 'module', 'b_feetype', 'access', 1, 40, N'模拟数据:模块使用权限'), + ('module.b_port', N'港口', 'module.b_port', 'module', 'module', 'b_port', 'access', 1, 50, N'模拟数据:模块使用权限'), + ('module.b_oceanline', N'航线', 'module.b_oceanline', 'module', 'module', 'b_oceanline', 'access', 1, 60, N'模拟数据:模块使用权限'), + ('module.b_vessel', N'船名船次', 'module.b_vessel', 'module', 'module', 'b_vessel', 'access', 1, 70, N'模拟数据:模块使用权限'), + ('module.b_depot', N'仓库', 'module.b_depot', 'module', 'module', 'b_depot', 'access', 1, 80, N'模拟数据:模块使用权限'); +GO + +/* ---------------------------------------------------------------------------- + 3. 业务动作权限:action.{模块编码}.{动作} + + 只为「主数据」类模块配业务动作(字典类模块通常只有使用权限)。 + 合作伙伴与联系人是有状态的业务对象,给完整的新增/修改/删除/审核。 + ---------------------------------------------------------------------------- */ + +insert into dbo.s_power (b_id, b_name, b_i18n, b_type, b_object_type, b_object_id, b_action, b_canuse, b_xh, b_bz) +values + -- 合作伙伴:新增 / 修改 / 删除 / 审核 + ('action.v_b_othercompany.create', N'新增', 'action.insert', 'action', 'module', 'v_b_othercompany', 'create', 1, 110, N'模拟数据:业务动作权限'), + ('action.v_b_othercompany.update', N'修改', 'action.update', 'action', 'module', 'v_b_othercompany', 'update', 1, 120, N'模拟数据:业务动作权限'), + ('action.v_b_othercompany.delete', N'删除', 'action.delete', 'action', 'module', 'v_b_othercompany', 'delete', 1, 130, N'模拟数据:业务动作权限'), + ('action.v_b_othercompany.audit', N'审核', 'action.audit', 'action', 'module', 'v_b_othercompany', 'audit', 1, 140, N'模拟数据:业务动作权限'), + -- 联系人:新增 / 修改 / 删除 + ('action.v_b_contact.create', N'新增', 'action.insert', 'action', 'module', 'v_b_contact', 'create', 1, 150, N'模拟数据:业务动作权限'), + ('action.v_b_contact.update', N'修改', 'action.update', 'action', 'module', 'v_b_contact', 'update', 1, 160, N'模拟数据:业务动作权限'), + ('action.v_b_contact.delete', N'删除', 'action.delete', 'action', 'module', 'v_b_contact', 'delete', 1, 170, N'模拟数据:业务动作权限'), + -- 基础字典:新增 / 修改 / 删除(字典维护是同一类操作) + ('action.b_money.create', N'新增', 'action.insert', 'action', 'module', 'b_money', 'create', 1, 180, N'模拟数据:业务动作权限'), + ('action.b_money.update', N'修改', 'action.update', 'action', 'module', 'b_money', 'update', 1, 190, N'模拟数据:业务动作权限'), + ('action.b_feetype.create', N'新增', 'action.insert', 'action', 'module', 'b_feetype', 'create', 1, 200, N'模拟数据:业务动作权限'), + ('action.b_feetype.update', N'修改', 'action.update', 'action', 'module', 'b_feetype', 'update', 1, 210, N'模拟数据:业务动作权限'), + ('action.b_port.create', N'新增', 'action.insert', 'action', 'module', 'b_port', 'create', 1, 220, N'模拟数据:业务动作权限'), + ('action.b_port.update', N'修改', 'action.update', 'action', 'module', 'b_port', 'update', 1, 230, N'模拟数据:业务动作权限'); +GO + +/* ---------------------------------------------------------------------------- + 4. 演示授权:给账号 001 写入初始权限 + + 模拟一个「基础资料维护员」:能维护币种 / 港口 / 费用类别,但只看合作伙伴、不能改。 + 取消勾选 = 删行(s_user_power 是「拥有」语义,不置 b_canuse = 0)。 + ---------------------------------------------------------------------------- */ + +delete from dbo.s_user_power where b_user_id = '001'; +GO + +insert into dbo.s_user_power (b_user_id, b_power_id, b_canuse, b_created_by, b_created_at) +values + -- 合作伙伴:只读(有使用权限,无任何业务动作) + ('001', 'module.v_b_othercompany', 1, 'system', sysdatetime()), + -- 联系人:可新增 / 修改 + ('001', 'module.v_b_contact', 1, 'system', sysdatetime()), + ('001', 'action.v_b_contact.create', 1, 'system', sysdatetime()), + ('001', 'action.v_b_contact.update', 1, 'system', sysdatetime()), + -- 基础字典:币种 / 港口 / 费用类别 —— 完整维护 + ('001', 'module.b_money', 1, 'system', sysdatetime()), + ('001', 'action.b_money.create', 1, 'system', sysdatetime()), + ('001', 'action.b_money.update', 1, 'system', sysdatetime()), + ('001', 'module.b_feetype', 1, 'system', sysdatetime()), + ('001', 'action.b_feetype.create', 1, 'system', sysdatetime()), + ('001', 'action.b_feetype.update', 1, 'system', sysdatetime()), + ('001', 'module.b_port', 1, 'system', sysdatetime()), + ('001', 'action.b_port.create', 1, 'system', sysdatetime()), + ('001', 'action.b_port.update', 1, 'system', sysdatetime()); +GO + +/* ---------------------------------------------------------------------------- + 5. 自检 + ---------------------------------------------------------------------------- */ + +select N's_power 总行数' as 项, count(*) as 行数 from dbo.s_power +union all +select N's_power 模拟行数', count(*) from dbo.s_power where b_bz like N'模拟数据%' +union all +select N' └ module 类型', count(*) from dbo.s_power where b_type = 'module' +union all +select N' └ action 类型', count(*) from dbo.s_power where b_type = 'action' +union all +select N's_user_power(001)', count(*) from dbo.s_user_power where b_user_id = '001'; +GO diff --git a/code/fms/sql/fms_module_user.sql b/code/fms/sql/fms_module_user.sql index fec4aa3e..7a146e18 100644 --- a/code/fms/sql/fms_module_user.sql +++ b/code/fms/sql/fms_module_user.sql @@ -27,13 +27,14 @@ GO ---------------------------------------------------------------------------- */ create or alter view dbo.v_b_user as --- 注意:视图刻意不含 b_password。列表接口按视图 SELECT 返回行,带上密码等于把 --- 全员口令(现为明文存储)随分页数据一起下发,抓包/DevTools 即可见。 --- 密码只在编辑表单里录入,来源于 s_field 定义,不需要出现在查询视图里。 +-- 注意:视图含 b_password(明文),供用户管理页的「查看密码」按钮按需展示。 +-- 该列在 view 配置里默认 visible:false,列表默认不显示;点按钮才由前端放开。 +-- b_password 只读:保存仍走 b_user,编辑表单的密码字段来源于 s_field 定义。 select u.b_id, u.b_name, u.b_dept_id, d.b_name as v_dept_name, -- 所属部门名称(派生,只读) + u.b_password, u.b_bz, u.b_canuse, u.b_logincount, @@ -116,6 +117,7 @@ values ('v_b_user', 'view', N'{ "schemaVersion": 1, "columns": [ { "field": "b_id", "width": 140 }, + { "field": "b_password", "width": 140, "visible": false }, { "field": "b_name", "width": 140 }, { "field": "v_dept_name", "width": 160 }, { "field": "b_canuse", "width": 90 }, diff --git a/code/one-designer/.gitignore b/code/one-designer/.gitignore new file mode 100644 index 00000000..5e9853a1 --- /dev/null +++ b/code/one-designer/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +coverage/ +*.tsbuildinfo +.turbo/ +.DS_Store +*.log +.vite/ diff --git a/code/one-designer/.playwright-cli/page-2026-09-20T08-38-59-933Z.yml b/code/one-designer/.playwright-cli/page-2026-09-20T08-38-59-933Z.yml new file mode 100644 index 00000000..e69de29b diff --git a/code/one-designer/.playwright-cli/page-2026-09-20T08-39-03-959Z.yml b/code/one-designer/.playwright-cli/page-2026-09-20T08-39-03-959Z.yml new file mode 100644 index 00000000..031e1e7a --- /dev/null +++ b/code/one-designer/.playwright-cli/page-2026-09-20T08-39-03-959Z.yml @@ -0,0 +1,61 @@ +- generic [ref=e3]: + - banner [ref=e4]: + - generic [ref=e5]: Web Report Designer · Demo + - button "🌙 暗色" [ref=e6] [cursor=pointer] + - main [ref=e7]: + - generic [ref=e8]: + - toolbar "工具栏" [ref=e9]: + - generic [ref=e10]: + - button "撤销" [disabled] [ref=e11] + - button "重做" [disabled] [ref=e12] + - generic [ref=e13]: + - button "缩小" [ref=e14] [cursor=pointer]: − + - generic [ref=e15]: 100% + - button "放大" [ref=e16] [cursor=pointer]: + + - button "预览" [ref=e18] [cursor=pointer] + - generic [ref=e19]: + - complementary "工具箱" [ref=e20]: + - generic [ref=e21]: 元素 + - button "文本" [ref=e22] [cursor=pointer] + - button "矩形" [ref=e23] [cursor=pointer] + - generic [ref=e24]: + - generic [ref=e25]: mm + - generic [aria-hidden] [ref=e26]: + - generic [ref=e27]: "0" + - generic [ref=e32]: "20" + - generic [ref=e37]: "40" + - generic [ref=e42]: "60" + - generic [ref=e47]: "80" + - generic [ref=e52]: "100" + - generic [ref=e57]: "120" + - generic [ref=e62]: "140" + - generic [ref=e67]: "160" + - generic [ref=e72]: "180" + - generic [ref=e77]: "200" + - generic [aria-hidden] [ref=e81]: + - generic [ref=e82]: "0" + - generic [ref=e87]: "20" + - generic [ref=e92]: "40" + - generic [ref=e97]: "60" + - generic [ref=e102]: "80" + - generic [ref=e107]: "100" + - generic [ref=e112]: "120" + - generic [ref=e117]: "140" + - generic [ref=e122]: "160" + - generic [ref=e127]: "180" + - generic [ref=e132]: "200" + - generic [ref=e137]: "220" + - generic [ref=e142]: "240" + - generic [ref=e147]: "260" + - generic [ref=e152]: "280" + - generic [ref=e159]: + - generic [ref=e160]: 销售出库单 + - generic [ref=e163]: 客户:示例公司 + - generic [ref=e165]: 日期:2025-01-01 + - generic [ref=e167]: 在左侧工具箱添加元素,点击画布元素可在右侧编辑属性,工具栏支持撤销/重做与缩放。 + - complementary "属性面板" [ref=e169]: + - generic [ref=e170]: 未选中元素 + - status [ref=e171]: + - generic [ref=e172]: 缩放 100% + - generic [ref=e173]: 单位 mm + - generic [ref=e174]: 元素 5 \ No newline at end of file diff --git a/code/one-designer/.playwright-cli/page-2026-09-20T08-40-02-544Z.yml b/code/one-designer/.playwright-cli/page-2026-09-20T08-40-02-544Z.yml new file mode 100644 index 00000000..75a0b309 --- /dev/null +++ b/code/one-designer/.playwright-cli/page-2026-09-20T08-40-02-544Z.yml @@ -0,0 +1,76 @@ +- generic [ref=e3]: + - banner [ref=e4]: + - generic [ref=e5]: Web Report Designer · Demo + - button "🌙 暗色" [ref=e6] [cursor=pointer] + - main [ref=e7]: + - generic [ref=e8]: + - toolbar "工具栏" [ref=e9]: + - generic [ref=e10]: + - button "撤销" [active] [ref=e11] [cursor=pointer] + - button "重做" [ref=e12] [cursor=pointer] + - generic [ref=e13]: + - button "缩小" [ref=e14] [cursor=pointer]: − + - generic [ref=e15]: 100% + - button "放大" [ref=e16] [cursor=pointer]: + + - button "预览" [ref=e18] [cursor=pointer] + - generic [ref=e19]: + - complementary "工具箱" [ref=e20]: + - generic [ref=e21]: 元素 + - button "文本" [ref=e22] [cursor=pointer] + - button "矩形" [ref=e23] [cursor=pointer] + - generic [ref=e24]: + - generic [ref=e25]: mm + - generic [aria-hidden] [ref=e26]: + - generic [ref=e27]: "0" + - generic [ref=e32]: "20" + - generic [ref=e37]: "40" + - generic [ref=e42]: "60" + - generic [ref=e47]: "80" + - generic [ref=e52]: "100" + - generic [ref=e57]: "120" + - generic [ref=e62]: "140" + - generic [ref=e67]: "160" + - generic [ref=e72]: "180" + - generic [ref=e77]: "200" + - generic [aria-hidden] [ref=e81]: + - generic [ref=e82]: "0" + - generic [ref=e87]: "20" + - generic [ref=e92]: "40" + - generic [ref=e97]: "60" + - generic [ref=e102]: "80" + - generic [ref=e107]: "100" + - generic [ref=e112]: "120" + - generic [ref=e117]: "140" + - generic [ref=e122]: "160" + - generic [ref=e127]: "180" + - generic [ref=e132]: "200" + - generic [ref=e137]: "220" + - generic [ref=e142]: "240" + - generic [ref=e147]: "260" + - generic [ref=e152]: "280" + - generic [ref=e159]: + - generic [ref=e160]: 销售出库单 + - generic [ref=e163]: 客户:示例公司 + - generic [ref=e165]: 日期:2025-01-01 + - generic [ref=e167]: 在左侧工具箱添加元素,点击画布元素可在右侧编辑属性,工具栏支持撤销/重做与缩放。 + - complementary "属性面板" [ref=e169]: + - generic [ref=e175]: 属性 + - generic [ref=e176]: + - generic [ref=e177]: ID + - generic [ref=e178]: title-1 + - generic [ref=e179]: + - generic [ref=e180]: 类型 + - generic [ref=e181]: text + - generic [ref=e182]: + - generic [ref=e183]: 位置 + - generic [ref=e184]: x23 y13 + - generic [ref=e185]: + - generic [ref=e186]: 尺寸 + - generic [ref=e187]: 120 × 12 + - generic [ref=e188]: + - generic [ref=e189]: 文本 + - textbox "文本" [ref=e190]: 销售出库单 + - status [ref=e171]: + - generic [ref=e172]: 缩放 100% + - generic [ref=e173]: 单位 mm + - generic [ref=e174]: 元素 5 \ No newline at end of file diff --git a/code/one-designer/.playwright-cli/page-2026-09-20T08-47-10-494Z.yml b/code/one-designer/.playwright-cli/page-2026-09-20T08-47-10-494Z.yml new file mode 100644 index 00000000..e69de29b diff --git a/code/one-designer/Web Report Designer 初期架构设计方案 V1.md b/code/one-designer/Web Report Designer 初期架构设计方案 V1.md index f50a173c..9d560ccf 100644 --- a/code/one-designer/Web Report Designer 初期架构设计方案 V1.md +++ b/code/one-designer/Web Report Designer 初期架构设计方案 V1.md @@ -1,20 +1,27 @@ # Web Report Designer 初期架构设计方案 V1 +> 文档状态:初期架构设计 +> +> 本版本目标是先建立稳定的文档模型、布局管线和编辑器边界,不在第一阶段一次性实现所有复杂报表能力。 + ## 1. 项目定位 项目暂定名称: -```text +~~~text Web Report Designer -``` +~~~ 包名暂定: -```text +~~~text @xxx/report-core +@xxx/report-expression +@xxx/report-layout +@xxx/report-renderer @xxx/report-designer @xxx/report-designer-vue -``` +~~~ 项目定位: @@ -29,17 +36,15 @@ Web Report Designer - 动态明细 - 多页打印 - 页眉页脚 -- 分组 -- 合计 +- 基础分组和合计 - 数据绑定 -- 表达式 -- PDF -- HTML -- 浏览器打印 +- 受限表达式 +- HTML、SVG 和浏览器打印 +- 可扩展的 PDF 输出接口 明确不包含: -```text +~~~text BI Dashboard OLAP @@ -47,37 +52,51 @@ OLAP 数据立方体 指标体系 大屏 -``` +~~~ ------- +### 1.1 V1 初期范围 -# 2. 总体架构 +V1 优先保证以下能力可用: + +1. 文本、图片、线条、矩形等固定元素。 +2. A4、A5 和自定义纸张。 +3. 基础数据绑定和明细表。 +4. 页眉、页脚和基础分页。 +5. HTML、SVG 预览以及浏览器打印。 +6. 文档序列化、校验和版本迁移。 +7. 可注册的元素插件接口。 + +以下能力先预留模型和接口,放到后续迭代: + +- 行列合并、跨页合计的分页级精细控制。 +- 图表、条码、二维码等插件实现。 +- 服务端 PDF 的具体实现。 +- 协同编辑、权限体系和 BI 能力。 + +> 实现进度补充:基础分组(group-header / group-footer、多级分组、组内聚合) +> 已提前完成,详见 §16 阶段 3。 + +## 2. 总体架构 整体采用 Monorepo。 -```text +~~~text report-designer/ │ ├── apps/ │ └── report-designer/ │ ├── packages/ -│ │ │ ├── report-core/ -│ │ │ ├── report-expression/ -│ │ │ ├── report-layout/ -│ │ │ ├── report-renderer/ -│ │ ├── html/ -│ │ ├── svg/ -│ │ └── pdf/ -│ │ +│ │ └── src/ +│ │ ├── html/ +│ │ ├── svg/ +│ │ └── pdf/ │ ├── report-designer/ -│ │ │ ├── report-designer-vue/ -│ │ │ └── plugins/ │ ├── qrcode/ │ ├── barcode/ @@ -85,127 +104,174 @@ report-designer/ │ └── ... │ ├── examples/ -│ ├── pnpm-workspace.yaml ├── package.json └── README.md -``` +~~~ -核心依赖关系: +### 2.1 依赖方向 -```text - report-core - ▲ - ┌─────────────┼─────────────┐ - │ │ │ - report-expression report-layout renderer - ▲ - │ - report-designer - ▲ - │ - report-designer-vue -``` +~~~text +report-expression ──> report-core +report-layout ──> report-core + report-expression +report-renderer ──> report-core + report-layout +report-designer ──> report-core + report-layout +report-designer-vue ──> report-designer +~~~ -插件: +上图箭头表示“依赖”。Core 不依赖上述任何实现包。 -```text - report-plugin-qrcode - │ - ┌────────┼────────┐ - ▼ ▼ ▼ - report-core designer renderer -``` +Renderer 只消费布局结果,不重新计算分页。插件实现可以依赖 Core、Layout、Renderer 或 Designer 的适配器,但 Core 不反向依赖任何具体插件。 ------- +~~~text +plugin-qrcode +├── core element contract +├── layout adapter +├── renderer adapter +└── designer adapter(可选) +~~~ -# 3. 最重要的架构原则 +### 2.2 运行时主流程 -## 3.1 Core 与 UI 完全分离 +~~~text +ReportDocument + │ + ▼ +数据提供与参数注入 + │ + ▼ +表达式解析与绑定求值 + │ + ▼ +LayoutEngine(测量、展开、换行、分页) + │ + ▼ +LayoutDocument(统一的分页结果) + │ + ├── HTML Renderer + ├── SVG Renderer + └── PDF Renderer +~~~ -`report-core`: +## 3. 核心架构原则 -```text +### 3.1 Core 与 UI 完全分离 + +report-core: + +~~~text 纯 TypeScript 无 Vue 无 React 无 DOM 无浏览器 API 无 UI 框架 -``` +~~~ -这样可以: +Core 可以在 Browser、Node.js、Electron、Server 和 CLI 中复用。 -```text -Browser -Node.js -Electron -Server -CLI -``` +### 3.2 文档模型与运行时状态分离 -都使用同一个 Core。 +ReportDocument 只保存可序列化的报表定义,不保存以下编辑器状态: ------- +- 当前选中项。 +- 缩放比例和画布滚动位置。 +- 辅助线、吸附线和临时选区。 +- 撤销栈和 redo 栈。 +- 已加载的数据和网络连接。 -## 3.2 Designer 不负责报表业务逻辑 - -Designer 负责: - -```text -选择 -拖拽 -缩放 -对齐 -属性编辑 -复制粘贴 -撤销重做 -快捷键 -画布 -``` - -Core 负责: - -```text -报表模型 -数据模型 -样式模型 -序列化 -插件注册 -``` +### 3.3 Layout 与 Renderer 分离 Layout 负责: -```text +~~~text 尺寸计算 +文本测量 换行 表格展开 动态高度 分页 -``` +~~~ Renderer 负责: -```text -HTML -SVG -PDF -``` +~~~text +把 LayoutDocument 绘制成 HTML、SVG 或 PDF +~~~ ------- +这样可以保证不同输出目标使用同一套分页结果。 -# 4. report-core 目录 +### 3.4 运行时数据由外部注入 -初期: +Core 不负责 fetch、数据库连接或 Token 管理。数据源定义和运行时数据提供器分开,避免把敏感配置写入报表文件。 -```text +### 3.5 文档必须可校验、可迁移 + +所有文档都必须带 schemaVersion,反序列化时先校验,再按版本逐步迁移。文档 JSON 中不能保存函数、DOM 节点或运行时对象。 + +## 4. 包职责 + +### 4.1 report-core + +- 报表文档模型。 +- 页面、区域、元素和样式模型。 +- 数据源、数据集、参数契约。 +- 插件契约和注册表。 +- 文档创建、克隆、规范化。 +- 序列化、反序列化、校验和迁移。 + +### 4.2 report-expression + +- 表达式词法和语法解析。 +- AST 和受限求值器。 +- 字段、参数、行、分组和页面作用域。 +- 表达式错误和诊断信息。 + +禁止通过 eval 或 new Function 执行任意 JavaScript。 + +### 4.3 report-layout + +- 元素尺寸测量。 +- 文本换行和动态高度。 +- 明细数据展开。 +- 分页和分页诊断。 +- 生成统一的 LayoutDocument。 + +Layout 通过 TextMeasurer、FontResolver 等接口获得环境能力,不直接依赖 DOM。 + +### 4.4 report-renderer + +- 消费 LayoutDocument。 +- 提供 HTML、SVG 和 PDF Renderer。 +- 处理目标格式相关的颜色、字体和资源输出。 + +PDF 的具体实现可以运行在浏览器或 Node/服务端,但必须遵循统一的 Renderer 接口。 + +### 4.5 report-designer + +- 画布和坐标转换。 +- 选择、拖拽、缩放、对齐和吸附。 +- 属性编辑。 +- 复制粘贴。 +- 命令、事务和撤销重做。 +- 快捷键和编辑器状态。 + +Designer 通过 Core 的命令接口修改文档,不直接实现报表业务规则。 + +### 4.6 report-designer-vue + +只负责 Vue 组件、面板和 UI 适配,不向 Core 泄漏 Vue 类型。 + +## 5. report-core 目录 + +~~~text packages/report-core/ │ ├── src/ -│ │ │ ├── model/ │ │ ├── report.ts │ │ ├── page.ts +│ │ ├── section.ts │ │ ├── element.ts │ │ ├── table.ts │ │ ├── style.ts @@ -237,96 +303,613 @@ packages/report-core/ │ │ ├── defaults.ts │ │ └── validation.ts │ │ +│ ├── diagnostics/ +│ │ └── diagnostic.ts │ ├── utils/ │ │ ├── id.ts │ │ └── deep.ts -│ │ │ └── index.ts │ └── tests/ -``` +~~~ ------- +## 6. Report 文档模型 -# 5. ReportDocument +报表采用“文档 + 区域 + 元素树”模型,不把所有元素平铺在根节点上。 -这是整个系统的根。 - -```ts +~~~ts export interface ReportDocument { schemaVersion: number id: string - name: string page: PageSettings styles: Record - dataSources: Record - - datasets: Record - + dataSources: Record + datasets: Record parameters: Record - elements: ReportElement[] + sections: ReportSection[] plugins?: ReportPluginReference[] - metadata?: ReportMetadata } -``` +~~~ -其中: +### 6.1 报表区域 -```ts -export interface ReportMetadata { - author?: string - description?: string - createdAt?: string - updatedAt?: string +~~~ts +export type ReportSectionKind = + | "report-header" + | "page-header" + | "detail" + | "group-header" + | "group-footer" + | "summary" + | "page-footer" + +export interface ReportSection { + id: string + kind: ReportSectionKind + + datasetId?: string + children: ReportElement[] + + options?: { + repeatOnEachPage?: boolean + keepTogether?: boolean + allowSplit?: boolean + } } -``` +~~~ ------- +V1 先实现 page-header、detail 和 page-footer,其他区域只保留模型扩展点。 -# 6. Page 模型 +> 实现进度:group-header / group-footer 已可用(含多级分组与组内聚合)。 +> report-header 与 summary 仍只保留模型扩展点。 +> +> 分组区域通过 `groupExpression` 指定分组键,`groupLevel` 指定嵌套级别: +> +> ~~~ts +> export interface ReportSection { +> // ...原有字段 +> /** 分组键表达式,仅 group-header / group-footer 使用。 */ +> groupExpression?: string +> /** 分组级别,从 1 开始;数值小的为外层组。 */ +> groupLevel?: number +> } +> ~~~ +> +> 分组语义为「**相邻行键值相同即同组**」,不改变行顺序—— +> 因此宿主应先按分组键排序再传入数据。组脚可通过 `group` 作用域访问 +> 聚合能力,如 `sum(group.amount)`、`group.count`、`group.key`。 -内部建议统一使用物理单位。 +### 6.2 元素树 -```ts -export type ReportUnit = - | "mm" - | "pt" - | "px" -``` +~~~ts +export interface ReportElement { + id: string + type: string -默认: + frame: Rect + styleId?: string -```text -mm -``` + binding?: ValueBinding + props?: Record + children?: ReportElement[] +} -Page: - -```ts -export interface PageSettings { +export interface Rect { + x: number + y: number width: number height: number +} - unit: ReportUnit +export interface ValueBinding { + field?: string + expression?: string + format?: string +} +~~~ +内置元素初期包括: + +~~~text +text +image +line +rect +container +table +~~~ + +插件元素使用自定义 type,并通过插件 Schema 验证 props。 + +### 6.3 明细表 + +明细表是 V1 的特殊元素,负责把数据集展开成多行。单元格内的元素坐标相对于当前单元格。 + +~~~ts +export interface TableElement extends ReportElement { + type: "table" + datasetId: string + columns: TableColumn[] + repeatHeaderOnEachPage?: boolean +} + +export interface TableColumn { + id: string + width: number + header?: string + binding?: ValueBinding + children?: ReportElement[] +} +~~~ + +### 6.4 样式 + +样式通过 ID 复用,并允许有限的继承和元素级覆盖: + +~~~ts +export interface StyleDefinition { + id: string + extends?: string + fontFamily?: string + fontSize?: number + color?: string + background?: string + border?: BorderStyle + align?: "left" | "center" | "right" + verticalAlign?: "top" | "middle" | "bottom" +} +~~~ + +## 7. Page 模型 + +V1 文档内部统一使用 mm,px 只用于 Designer 显示层,pt 只在 PDF 等输出边界转换。 + +~~~ts +export interface PageSettings { + paper: "A4" | "A5" | "custom" orientation: "portrait" | "landscape" - margin: PageMargin + customSize?: { + width: number + height: number + } + margin: PageMargin background?: string } + export interface PageMargin { top: number right: number bottom: number left: number } -``` \ No newline at end of file +~~~ + +paper 和 orientation 是页面配置来源;Layout 阶段将其解析为最终的 width、height。自定义纸张必须提供尺寸,尺寸均以 mm 表示。 + +## 8. 数据和运行时上下文 + +报表文档只保存数据源定义,不保存连接、Token 和本次查询结果。 + +~~~ts +export interface DataSourceDefinition { + id: string + type: string + config?: Record + connectionRef?: string +} + +export interface DatasetDefinition { + id: string + sourceId?: string + fields?: DatasetField[] + query?: string +} + +export interface RenderContext { + data: Record + parameters: Record + locale?: string + timezone?: string +} + +export interface DataProvider { + getDataset( + dataset: DatasetDefinition, + context: RenderContext + ): Promise>> +} +~~~ + +DataProvider 由宿主应用注入,Core 不主动访问网络或数据库。 +config 只保存非敏感的设计配置;连接密码、Token 等敏感信息必须通过 connectionRef 或宿主运行时注入。 + +## 9. Layout 中间结果 + +Layout Engine 接受报表文档和运行时上下文,输出所有 Renderer 共用的分页结果。 + +~~~ts +export interface LayoutOptions { + dataProvider?: DataProvider + textMeasurer: TextMeasurer + fontResolver: FontResolver +} + +export interface LayoutEngine { + layout( + document: ReportDocument, + context: RenderContext, + options?: LayoutOptions + ): Promise +} + +export interface LayoutDocument { + pages: LayoutPage[] + diagnostics: LayoutDiagnostic[] +} + +export interface LayoutPage { + width: number + height: number + nodes: LayoutNode[] +} + +export interface LayoutNode { + id: string + type: string + frame: Rect + style: ResolvedStyle + content?: unknown + children?: LayoutNode[] +} +~~~ + +Layout 必须支持以下接口: + +~~~ts +export interface TextMeasurer { + measure(text: string, style: ResolvedStyle): TextMetrics +} + +export interface FontResolver { + resolve(fontFamily: string): ResolvedFont +} +~~~ + +这样可以分别适配浏览器、Node 和服务端字体环境,同时让分页逻辑保持独立。 + +## 10. 表达式约定 + +表达式系统使用受限语法和 AST,不执行任意 JavaScript。 + +V1 至少支持: + +~~~text +字段:row.amount +参数:params.customerName +简单运算:row.price * row.quantity +条件:if(row.amount > 0, "有", "无") +格式化:format(row.date, "YYYY-MM-DD") +~~~ + +表达式失败时返回带有元素 ID 和表达式内容的诊断信息,单个字段错误不应导致整个文档无结果。 + +## 11. 插件模型 + +插件只通过契约接入,不允许 Core 直接导入插件实现。 + +~~~ts +export interface ReportPlugin { + id: string + version: string + elements?: ElementPlugin[] + datasources?: DataSourcePlugin[] + exporters?: ExporterPlugin[] +} + +export interface ElementPlugin { + type: string + schema: unknown + createDefault(): ReportElement +} +~~~ + +需要针对不同运行环境提供可选适配器: + +~~~text +ElementPlugin +├── LayoutAdapter +├── RendererAdapter(html/svg/pdf) +└── DesignerAdapter +~~~ + +插件引用只保存 id、version 和配置。插件缺失时应产生诊断信息,而不是阻止整个文档被读取。 + +## 12. 序列化、校验和迁移 + +- schemaVersion 使用单调递增整数。 +- 反序列化顺序为:解析 JSON → 校验结构 → 执行迁移 → 规范化默认值。 +- 迁移函数按版本逐步执行,不允许跨版本直接修改内部对象。 +- 序列化结果保持稳定排序,便于版本控制和差异比较。 +- 校验错误必须指出字段路径和元素 ID。 + +示例: + +~~~text +deserialize(json) + → validate(json) + → migrate(json, currentVersion) + → normalize(document) +~~~ + +## 13. 诊断和错误处理 + +统一使用诊断对象表达警告和错误: + +~~~ts +export interface Diagnostic { + severity: "info" | "warning" | "error" + code: string + message: string + path?: string + elementId?: string +} +~~~ + +数据缺失、表达式错误、字体缺失、插件缺失和分页失败都应尽量返回可定位的诊断信息。 + +## 14. UI 规范 + +### 14.1 设计目标 + +Designer 的界面采用现代、简洁、干净的工具型产品风格,参考 shadcn/ui 的设计语言,但不要求 Core 或业务应用必须绑定某一个 UI 框架。 + +核心原则: + +- 画布优先,减少非必要装饰。 +- 使用中性背景、细边框和低强度阴影建立层次。 +- 通过间距、字号和颜色层级表达信息,不依赖渐变和大面积高亮。 +- 常用操作保持短路径,复杂配置分组收起。 +- 所有颜色、间距、圆角和阴影使用设计令牌,禁止组件内散落硬编码。 + +### 14.2 UI 层边界 + +- report-core 不包含任何 UI、CSS 或主题代码。 +- report-designer 负责编辑器状态、命令和交互行为。 +- report-designer-vue 负责 Vue 组件、面板和样式实现。 +- 组件采用 Headless 思路,宿主应用可以覆盖主题和品牌色。 +- 可以使用 shadcn-vue 或同类组件实现,但必须通过 report-designer-vue 隔离依赖。 + +### 14.3 整体布局 + +桌面端采用三栏、画布居中的工作区: + +~~~text +┌──────────────────────────────────────────────────────────────┐ +│ 顶部工具栏:文件、撤销、重做、预览、保存、缩放、主题 │ +├──────────────┬───────────────────────────────┬───────────────┤ +│ 左侧工具箱 │ │ 右侧属性面板 │ +│ 元素 / 数据 │ 报表画布 │ 属性 / 样式 │ +│ 模板 / 图层 │ │ 数据绑定 │ +├──────────────┴───────────────────────────────┴───────────────┤ +│ 底部状态栏:页码、缩放、单位、布局诊断、快捷键提示 │ +└──────────────────────────────────────────────────────────────┘ +~~~ + +建议尺寸: + +- 左侧工具箱:240px 左右,可折叠。 +- 右侧属性面板:320px 左右,可折叠。 +- 顶部工具栏:48px。 +- 底部状态栏:28px。 +- 中间画布区域自适应,并支持拖拽调整两侧面板宽度。 + +屏幕宽度小于 900px 时,侧栏改为抽屉或浮层;移动端优先保证预览和基础属性编辑,不强行复刻完整桌面编辑器。 + +### 14.4 设计令牌 + +使用 CSS 自定义属性作为主题契约,至少提供亮色和暗色两套主题: + +~~~css +:root { + --rd-background: 0 0% 100%; + --rd-foreground: 222 47% 11%; + --rd-muted: 210 40% 96%; + --rd-muted-foreground: 215 16% 47%; + --rd-border: 214 32% 91%; + --rd-input: 214 32% 91%; + --rd-primary: 221 83% 53%; + --rd-primary-foreground: 210 40% 98%; + --rd-destructive: 0 72% 51%; + --rd-canvas: 220 14% 96%; + --rd-selection: 221 83% 53%; + --rd-radius: 8px; +} +~~~ + +具体色值可以由宿主应用覆盖,但必须保持以下语义: + +- background:应用和面板背景。 +- canvas:画布工作区背景。 +- foreground:主要文字和图标。 +- muted:次级区域和禁用背景。 +- border:分隔线和输入框边框。 +- primary:主操作、选中态和焦点环。 +- destructive:删除和不可逆操作。 +- selection:画布元素选中框和辅助线。 + +推荐使用 OKLCH 或 HSL 变量,不在组件中直接写十六进制颜色。 + +### 14.5 字体和间距 + +~~~css +font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Microsoft YaHei", sans-serif; +~~~ + +推荐字号:12px(辅助信息)、13px(表单和工具栏)、14px(正文)、16px(小标题)、20px(页面标题)。 + +间距使用 4px 基准:4、8、12、16、20、24、32px。默认圆角 6~8px,面板阴影保持低强度,避免卡片堆叠感。 + +### 14.6 基础组件风格 + +基础组件遵循扁平、克制、可聚焦的风格: + +- Button:Primary、Secondary、Ghost、Destructive、Icon 五类。 +- Input、Textarea、Select、Combobox:1px 边框,聚焦时显示明显但克制的 focus ring。 +- Tabs、Segmented Control:用于属性面板和视图切换。 +- Popover、Tooltip、Context Menu、Command Menu:用于低频和快捷操作。 +- Dialog、Toast、Alert:用于确认、反馈和错误提示。 +- Resizable Panel:用于左右侧栏和属性面板。 + +按钮不使用过度圆润的胶囊形,图标按钮必须提供 Tooltip 和可访问名称。删除等危险操作不能只依赖颜色区分。 + +### 14.7 画布规范 + +- 工作区使用浅灰或深灰中性背景,页面使用白色或主题纸张颜色。 +- 页面保持轻微阴影和清晰边界,不使用强烈投影。 +- 元素 Hover 使用低透明度背景,Selected 使用 1px 主色边框和 8 个缩放控制点。 +- 对齐线、吸附线和标尺使用辅助色,不遮挡元素内容。 +- 支持 50%、75%、100%、125%、150%、200% 常用缩放。 +- 画布操作同时支持鼠标、键盘和上下文菜单,不把拖拽作为唯一入口。 +- 选中态、错误态和辅助线不能只通过颜色表达,必要时增加边框、图标或文字提示。 + +### 14.8 属性面板规范 + +- 按“布局、内容、样式、数据绑定、高级”分组。 +- 分组默认折叠低频配置,保留最近使用状态。 +- 数值输入显示单位,支持键盘微调和批量修改。 +- 绑定字段提供字段选择器,同时允许直接输入表达式。 +- 无选中元素时显示页面属性;多选时只显示共同属性。 +- 表单错误就地显示,并关联到对应字段,不使用只在底部出现的泛化错误。 + +### 14.9 交互和状态 + +顶部工具栏至少提供: + +- 新建、打开、保存。 +- 撤销、重做。 +- 预览、打印。 +- 缩放和适应页面。 +- 主题切换和帮助入口。 + +推荐快捷键:Ctrl/Cmd + S 保存、Ctrl/Cmd + Z 撤销、Ctrl/Cmd + Shift + Z 重做、Delete 删除、Ctrl/Cmd + C/V 复制粘贴、Ctrl/Cmd + K 打开命令菜单。 + +所有异步操作都要有 Loading、Empty、Error 和 Disabled 状态;保存成功、布局警告和表达式错误使用统一 Toast 或诊断面板反馈。 + +### 14.10 主题、密度和无障碍 + +- 默认提供 Light 和 Dark 两套主题。 +- 支持 Standard 和 Compact 两种密度,默认 Standard。 +- 颜色对比度目标达到 WCAG AA,正文至少 4.5:1。 +- 所有交互控件支持键盘访问,焦点状态必须可见。 +- 弹窗、抽屉、菜单和属性面板提供正确的 ARIA 语义。 +- 点击目标不小于 32px,主要触控目标建议不小于 36px。 +- 尊重 prefers-reduced-motion,避免非必要动画。 + +### 14.11 UI 验收标准 + +初期 UI 至少应满足: + +1. 用户可以在不阅读文档的情况下找到新增元素、保存、预览和撤销操作。 +2. 画布、工具箱和属性面板在亮色和暗色主题下都能正常使用。 +3. 选中、禁用、错误、加载和空状态具有一致的视觉语言。 +4. 关键操作可以只使用键盘完成。 +5. 主题颜色可以由宿主应用通过 CSS 变量覆盖。 + +## 15. 测试策略 + +初期至少建立以下测试: + +- √ 文档 Schema、默认值和版本迁移测试。 +- √ 固定元素和明细表的布局快照测试。 +- √ 页眉、页脚和多页分页测试。 +- HTML、SVG、PDF Renderer 的公共布局结果一致性测试。 +- √ 表达式安全性、空值和类型转换测试。 +- √ 中文字体、长文本和自定义纸张测试。 +- √ 插件缺失和插件版本不兼容测试。 + +建议准备一个固定的示例报表作为跨包契约测试数据。 + +> 当前测试规模:`report-core` 139 例、`report-expression` 111 例、 +> `report-layout` 124 例、`report-renderer` 12 例、`report-designer` 14 例、 +> `report-designer-vue` 11 例,合计 **411** 例。 +> 字体相关用例依赖系统字体,在缺少字体的环境中自动跳过。 +> HTML/SVG 分页一致性测试已随 Renderer 实现补齐。 + +## 16. 初期实施顺序 + +> 进度标记说明:`√` 表示已实现并有测试覆盖;`◐` 表示部分实现;未标记表示尚未开始。 +> +> 当前进度:阶段 1、2、3 已完成;阶段 4 主体完成(Renderer、Designer 核心、 +> Vue UI 骨架),Vue 层的图层面板、数据绑定面板、快捷键留待后续迭代。 + +### √ 阶段 1:Core 和文档模型 + +- √ 完成 ReportDocument、Section、Element、Page、Style。 +- √ 完成默认值、校验、序列化和迁移。 +- √ 完成基础命令和 ID 生成。 + +产物:`packages/report-core`(139 个测试用例)。 + +### √ 阶段 2:基础 Layout + +- √ 完成单页固定元素布局。 +- √ 完成文本测量、换行和基础分页。 +- √ 输出统一的 LayoutDocument。 + +产物:`packages/report-layout`(含自研 TTF/TTC 字体度量解析)。 + +### √ 阶段 3:数据和明细表 + +- √ 接入 RenderContext 和 DataProvider。 +- √ 支持字段绑定、简单表达式和明细表展开。 +- √ 支持页眉、页脚重复。 + +产物:`packages/report-expression`(83+28 个测试用例)、`packages/report-layout` 的 +数据绑定与分组能力。 + +补充实现(超出本阶段原始列项): + +- √ 受限表达式系统(字段、参数、运算、条件、格式化)。 +- √ 聚合函数(sum / avg / count / min / max)。 +- √ 基础分组(group-header / group-footer、多级分组、组内聚合)。 +- √ 明细表表头每页重复。 + +### ◐ 阶段 4:Renderer 和 Designer + +- √ 实现 SVG、HTML 和浏览器打印(`@report/renderer`,含 HTML/SVG 分页一致性测试)。 +- √ 实现 PDF Renderer 契约(可注入 `PdfBackend`,缺失/报错走诊断)。 +- √ 完成 Designer 的选择、拖拽、属性编辑和撤销重做(`@report/designer`,纯逻辑层)。 +- √ Vue 层 UI 骨架(`@report/designer-vue`,原生 Vue3 + Vite 库模式,无第三方组件库): + 三栏布局壳、画布(渲染/选中/新增)、工具栏(撤销重做/缩放)、属性面板(基础编辑)、 + 状态栏、设计令牌(亮/暗主题 CSS 变量)。 +- 待后续迭代:图层面板、数据绑定面板、拖拽移动、快捷键、预览接入 Renderer。 + +### 阶段 5:插件和 PDF + +- 实现二维码、条码等独立插件。 +- 根据部署环境确定 PDF 的具体后端。 +- 补充字体、资源和输出一致性测试。 + +## 17. V1 验收标准 + +一个示例报表应当能够: + +1. 在 Designer 中创建并保存。 +2. 绑定一组运行时数据。 +3. 生成包含页眉、明细和页脚的多页结果。 +4. 在 HTML 和 SVG 中得到相同的分页结构。 +5. 通过浏览器打印输出。 +6. 在升级 Schema 后仍可被迁移和读取。 + +达到以上标准后,再继续扩展复杂分组、图表、PDF 和更多插件。 diff --git a/code/one-designer/apps/demo/index.html b/code/one-designer/apps/demo/index.html new file mode 100644 index 00000000..d31f7a37 --- /dev/null +++ b/code/one-designer/apps/demo/index.html @@ -0,0 +1,12 @@ + + + + + + 报表设计器 Demo + + + + + + diff --git a/code/one-designer/apps/demo/package.json b/code/one-designer/apps/demo/package.json new file mode 100644 index 00000000..2e1d808b --- /dev/null +++ b/code/one-designer/apps/demo/package.json @@ -0,0 +1,24 @@ +{ + "name": "@report/demo", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@report/core": "workspace:*", + "@report/layout": "workspace:*", + "@report/designer": "workspace:*", + "@report/designer-vue": "workspace:*", + "@report/renderer": "workspace:*", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } +} diff --git a/code/one-designer/apps/demo/src/App.vue b/code/one-designer/apps/demo/src/App.vue new file mode 100644 index 00000000..dc82d61f --- /dev/null +++ b/code/one-designer/apps/demo/src/App.vue @@ -0,0 +1,144 @@ + + + + + + Web Report Designer · Demo + + {{ isDark ? "☀️ 亮色" : "🌙 暗色" }} + + + + + + + + + diff --git a/code/one-designer/apps/demo/src/main.ts b/code/one-designer/apps/demo/src/main.ts new file mode 100644 index 00000000..b701a8af --- /dev/null +++ b/code/one-designer/apps/demo/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from "vue" +// 组件样式由库构建产出,宿主需显式引入(设计令牌由 injectThemeStyle 注入)。 +import "@report/designer-vue/style.css" +import App from "./App.vue" + +createApp(App).mount("#app") diff --git a/code/one-designer/apps/demo/vite.config.ts b/code/one-designer/apps/demo/vite.config.ts new file mode 100644 index 00000000..5f28a97f --- /dev/null +++ b/code/one-designer/apps/demo/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite" +import vue from "@vitejs/plugin-vue" + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5180, + host: "127.0.0.1", + }, +}) diff --git a/code/one-designer/n1.yaml b/code/one-designer/n1.yaml new file mode 100644 index 00000000..e69de29b diff --git a/code/one-designer/package.json b/code/one-designer/package.json new file mode 100644 index 00000000..6e9ed5d7 --- /dev/null +++ b/code/one-designer/package.json @@ -0,0 +1,23 @@ +{ + "name": "report-designer-monorepo", + "version": "0.0.0", + "private": true, + "description": "Web Report Designer —— 面向业务系统的纯 Web、可嵌入、插件式报表设计与渲染平台", + "type": "module", + "packageManager": "pnpm@11.8.0", + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "pnpm -r --workspace-concurrency=1 --filter \"./packages/**\" run build", + "test": "pnpm -r --workspace-concurrency=1 --filter \"./packages/**\" run test", + "typecheck": "pnpm -r --workspace-concurrency=1 --filter \"./packages/**\" run typecheck", + "clean": "pnpm -r exec rm -rf dist .turbo node_modules/.cache" + }, + "devDependencies": { + "typescript": "^5.7.2", + "tsup": "^8.3.5", + "vitest": "^2.1.8", + "@types/node": "^22.10.2" + } +} diff --git a/code/one-designer/packages/report-core/README.md b/code/one-designer/packages/report-core/README.md new file mode 100644 index 00000000..0385a8fb --- /dev/null +++ b/code/one-designer/packages/report-core/README.md @@ -0,0 +1,203 @@ +# @report/core + +报表文档模型、插件契约、校验、序列化与迁移。这是 Web Report Designer 的最底层包。 + +> 对应设计文档 §4.1、§5、§6、§7、§12、§13。 + +## 定位 + +`@report/core` 是**纯 TypeScript** 包:无 Vue / React、无 DOM、无浏览器 API、无 UI 框架。 +因此它可以在 Browser、Node.js、Electron、Server 和 CLI 中复用(架构原则 §3.1)。 + +Core 不依赖任何上层实现包,也不反向依赖任何具体插件。 + +## 安装与使用 + +```bash +pnpm add @report/core +``` + +```ts +import { + createDocument, + serialize, + deserialize, + createEditSession, + createCommand, + resolvePageSize, +} from "@report/core" + +// 新建文档 +const doc = createDocument({ name: "销售单", page: { paper: "A4" } }) + +// 解析纸张尺寸(mm) +const size = resolvePageSize(doc.page) // { width: 210, height: 297 } + +// 序列化(稳定排序,便于版本控制 diff) +const json = serialize(doc, { pretty: true }) + +// 反序列化:parse → validate → migrate → normalize +const result = deserialize(json) +if (result.ok) { + console.log(result.document?.name) +} +console.log(result.diagnostics) +``` + +## 核心概念 + +### 文档模型:文档 → 区域 → 元素树 + +不把所有元素平铺在根节点上。区域(`ReportSection`)有 7 种 `kind`,V1 只实现 +`page-header` / `detail` / `page-footer`,其余保留模型扩展点。 + +### 单位约定 + +文档内部统一使用 **mm**;px 只用于 Designer 显示层,pt 只在 PDF 等输出边界转换(§7)。 +`paper` + `orientation` 是配置来源,由 `resolvePageSize` 在布局阶段解析为最终 width/height。 + +### 纯数据约束 + +`ReportDocument` 只保存可序列化的报表定义,**不保存**编辑器状态(§3.2): +当前选中项、缩放比例、滚动位置、辅助线、撤销栈、已加载数据和网络连接均不得进入文档。 +`serialize` 会在规范化之前先断言纯数据性,混入函数或 DOM 节点会直接抛 `TypeError`。 + +### 诊断而非异常 + +数据缺失、表达式错误、字体缺失、插件缺失、分页失败都通过 `Diagnostic` 表达, +并尽量带上 `path`(JSON Pointer 风格字段路径)与 `elementId`,不中断文档读取(§13)。 + +```ts +import { DIAGNOSTIC_CODES, DiagnosticBag, formatDiagnostic } from "@report/core" + +const bag = new DiagnosticBag() +bag.warn(DIAGNOSTIC_CODES.REF_DANGLING_DATASET, "区域引用了不存在的数据集", { + path: "/sections/1/datasetId", +}) +console.log(bag.all().map(formatDiagnostic)) +``` + +### 序列化与迁移流程(§12) + +``` +deserialize(json) + → parse 解析 JSON + → validate 校验结构 + → migrate 按版本逐步迁移 + → normalize 规范化默认值 +``` + +- `schemaVersion` 使用单调递增整数,当前为 `CURRENT_SCHEMA_VERSION`。 +- 迁移函数**按版本逐步执行**,`planMigrations` 会强制 `to === from + 1`,禁止跨版本跳跃。 +- 序列化结果按键名稳定排序,便于 diff。 +- 版本高于当前支持时**拒绝读取**,避免降级写坏数据。 + +新增 schema 版本时,在 `src/schema/version.ts` 的 `MIGRATIONS` 中追加一项即可。 + +### 命令与撤销重做 + +Designer 通过 Core 的命令接口修改文档,不直接实现报表业务规则(§4.5)。 +**撤销栈属于编辑器状态,不进入文档**(§3.2),因此 `History` 由调用方持有。 + +```ts +import { createCommand, createEditSession } from "@report/core" + +const rename = createCommand<{ name: string }>("rename", (doc, payload) => { + const next = JSON.parse(JSON.stringify(doc)) + next.name = payload.name + return next +}) + +const session = createEditSession(createDocument({ name: "A" })) +session.execute(rename, { name: "B" }) +session.undo() // 回到 "A" +session.redo() // 回到 "B" + +// 事务:多次编辑合并为一步撤销 +session.transaction("批量调整", (s) => { + s.applyWithinTransaction(rename, { name: "C" }) + s.applyWithinTransaction(rename, { name: "D" }) +}) +``` + +撤销采用**快照式**而非反向命令式:文档是纯数据且体量可控,快照成本可接受, +且天然正确处理「一个事务改了多处」的情况。`mergeKey` + `mergeWindow` 用于合并 +拖拽、连续输入这类高频操作。 + +### 插件 + +插件只通过契约接入,Core 不导入任何具体插件实现(§11)。 +插件缺失或版本不兼容只产生诊断,不阻止文档读取。 + +```ts +import { PluginRegistry } from "@report/core" + +const registry = new PluginRegistry() +registry.register({ + id: "plugin-qrcode", + version: "1.2.0", + compatibleSchemaVersion: ">=1.0.0", + elements: [ + { + type: "qrcode", + createDefault: (options) => ({ + id: options?.id ?? "el-1", + type: "qrcode", + frame: options?.frame ?? { x: 0, y: 0, width: 20, height: 20 }, + props: { value: "https://example.com" }, + }), + }, + ], +}) + +// 校验文档时传入插件类型集合,避免误报未知元素类型 +const result = deserialize(json, { pluginElementTypes: registry.elementTypes() }) +``` + +`ElementPlugin.schema` 使用 `unknown` 是有意为之:Core 不绑定任何具体校验库, +宿主可以传 JSON Schema、Zod、Valibot 或自定义描述,由插件适配器负责具体校验。 + +## 目录结构 + +``` +src/ +├── model/ 报表文档模型(report / page / section / element / table / style / …) +├── plugin/ 插件契约与注册表 +├── document/ 创建、克隆、规范化 +├── serialization/ 序列化、反序列化、迁移 +├── schema/ 版本、默认值、校验 +├── command/ 命令、事务、撤销重做 +├── diagnostics/ 统一诊断对象 +├── utils/ ID 生成、深拷贝与对象工具 +└── index.ts 公共出口 +``` + +## 开发 + +```bash +pnpm build # tsup 打包(ESM + CJS + d.ts) +pnpm test # vitest +pnpm typecheck # tsc --noEmit +``` + +## 当前测试覆盖 + +| 测试文件 | 用例数 | 覆盖内容 | +| --- | --- | --- | +| `document.test.ts` | 19 | 创建、规范化、克隆、元素计数 | +| `serialization.test.ts` | 26 | 稳定序列化、往返、迁移链路、容错读取 | +| `validation.test.ts` | 34 | 结构校验、引用完整性、样式环、插件类型 | +| `command.test.ts` | 35 | 命令、合并、事务、撤销重做 | +| `plugin.test.ts` | 25 | 注册、冲突、版本兼容、缺失诊断 | + +合计 **139** 个用例。 + +## V1 范围内尚未实现的部分 + +本包只覆盖设计文档 §16 的**阶段 1**(Core 和文档模型)。以下属于后续阶段: + +- `report-expression`:表达式解析与受限求值器(阶段 3)。 +- `report-layout`:测量、换行、分页,产出 `LayoutDocument`(阶段 2)。 +- `report-renderer`:HTML / SVG / PDF(阶段 4)。 +- `report-designer` / `report-designer-vue`:编辑器与 UI(阶段 4)。 +- 插件实现:二维码、条码、图表(阶段 5)。 diff --git a/code/one-designer/packages/report-core/package.json b/code/one-designer/packages/report-core/package.json new file mode 100644 index 00000000..8472761d --- /dev/null +++ b/code/one-designer/packages/report-core/package.json @@ -0,0 +1,29 @@ +{ + "name": "@report/core", + "version": "0.1.0", + "description": "报表文档模型、插件契约、校验、序列化与迁移", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/code/one-designer/packages/report-core/src/command/command.ts b/code/one-designer/packages/report-core/src/command/command.ts new file mode 100644 index 00000000..e618fc07 --- /dev/null +++ b/code/one-designer/packages/report-core/src/command/command.ts @@ -0,0 +1,159 @@ +/** + * 命令模型(对应设计文档 §4.5 与 §3.2)。 + * + * 设计要点: + * - Designer 通过 Core 的命令接口修改文档,不直接实现报表业务规则。 + * - 撤销栈属于编辑器状态,不进文档(§3.2)。因此 History 由调用方持有, + * Core 只提供命令契约与执行语义。 + * + * 命令采用「整体替换文档」而非原地修改,原因是文档是纯数据、体量可控, + * 整体替换能天然保证不可变性和撤销快照的正确性,避免细粒度 patch 漏改。 + */ +import type { ReportDocument } from "../model/report.js" +import type { Diagnostic } from "../model/common.js" +import { cloneDocument } from "../document/clone.js" + +/** 命令执行上下文。 */ +export interface CommandContext { + /** 当前文档(只读,命令不得直接修改)。 */ + readonly document: ReportDocument +} + +/** 命令执行结果。 */ +export interface CommandResult { + /** 成功时必须返回新的文档;失败时返回 null。 */ + document: ReportDocument | null + diagnostics: Diagnostic[] +} + +/** 命令契约。 */ +export interface Command { + /** 命令名,用于日志、快捷键映射和调试。 */ + readonly type: string + /** 是否可撤销。默认 true。 */ + readonly undoable?: boolean + /** 合并键:相邻且相同 mergeKey 的命令在一定时间窗内合并为一步撤销。 */ + readonly mergeKey?: string + /** + * 执行命令。 + * + * 实现应当是纯函数:不得修改 context.document,只返回新文档。 + */ + execute(payload: TPayload, context: CommandContext): CommandResult +} + +/** 便捷构造:成功结果。 */ +export function success(document: ReportDocument): CommandResult { + return { document, diagnostics: [] } +} + +/** 便捷构造:失败结果。 */ +export function failure(diagnostics: Diagnostic | Diagnostic[]): CommandResult { + return { + document: null, + diagnostics: Array.isArray(diagnostics) ? diagnostics : [diagnostics], + } +} + +/** + * 定义命令的辅助函数。 + * + * 主要用于类型推断:让 payload 类型从 execute 的参数推导出来。 + */ +export function defineCommand( + command: Command, +): Command { + return command +} + +/** + * 基于「取当前文档 → 返回新文档」的函数式惯例构造命令。 + * + * 多数命令不需要访问 payload 以外的信息,用这个包装最省事。 + */ +export function createCommand( + type: string, + handler: (document: ReportDocument, payload: TPayload) => CommandResult | ReportDocument, + options: { undoable?: boolean; mergeKey?: string } = {}, +): Command { + const command: Command = { + type, + execute(payload: TPayload, context: CommandContext): CommandResult { + const result = handler(context.document, payload) + if (isCommandResult(result)) return result + return success(result) + }, + ...(options.undoable !== undefined ? { undoable: options.undoable } :{}), + ...(options.mergeKey !== undefined ? { mergeKey: options.mergeKey } :{}), + } + return command +} + +function isCommandResult(value: unknown): value is CommandResult { + return ( + typeof value === "object" && + value !== null && + "document" in value && + "diagnostics" in value + ) +} + +/** 判断命令是否可撤销。 */ +export function isUndoable(command: Command): boolean { + return command.undoable !== false +} + +/** + * 命令执行器。 + * + * 负责执行命令并把结果交给调用方,自身不保存状态。 + * 撤销栈由 History(见 history.ts)管理。 + */ +export class CommandInvoker { + private document: ReportDocument + + constructor(document: ReportDocument) { + this.document = document + } + + /** 当前文档。 */ + getDocument(): ReportDocument { + return this.document + } + + /** 直接替换文档(用于加载、撤销、重做)。 */ + setDocument(document: ReportDocument): void { + this.document = document + } + + /** 执行命令。失败时文档不变,返回 null。 */ + run(command: Command, payload: TPayload): CommandResult { + const result = command.execute(payload, { + document: this.document, + }) + if (result.document) { + this.document = result.document + } + return result + } + + /** 执行命令并保留旧文档快照,便于撤销。 */ + runWithSnapshot( + command: Command, + payload: TPayload, + ): { result: CommandResult; before: ReportDocument | null } { + const before = isUndoable(command as Command) + ? cloneDocument(this.document) + : null + const result = this.run(command, payload) + if (!result.document) { + return { result, before: null } + } + return { result, before } + } +} + +/** 创建命令执行器。 */ +export function createInvoker(document: ReportDocument): CommandInvoker { + return new CommandInvoker(document) +} diff --git a/code/one-designer/packages/report-core/src/command/history.ts b/code/one-designer/packages/report-core/src/command/history.ts new file mode 100644 index 00000000..1568731b --- /dev/null +++ b/code/one-designer/packages/report-core/src/command/history.ts @@ -0,0 +1,460 @@ +/** + * 事务与撤销/重做(对应设计文档 §4.5 与 §3.2)。 + * + * 重要边界:撤销栈和 redo 栈属于「编辑器状态」,明确禁止写入文档(§3.2)。 + * 因此 History 是一个独立对象,由 Designer 持有,Core 只提供通用实现。 + * + * 实现选择快照式(snapshot-based)撤销而非反向命令式,原因: + * - 文档是纯数据且体量可控,快照成本可接受。 + * - 反向命令需要每个命令实现 undo,容易出错且难以覆盖复杂编辑。 + * - 快照天然正确处理「一个事务改了多处」的情况。 + */ +import type { ReportDocument } from "../model/report.js" +import type { Diagnostic } from "../model/common.js" +import { cloneDocument } from "../document/clone.js" +import { deepEqual } from "../utils/deep.js" +import type { Command, CommandContext, CommandResult } from "./command.js" + +/** 一步可撤销的操作。 */ +export interface HistoryEntry { + /** 便于调试的操作名。 */ + label: string + /** 操作前的文档快照。 */ + before: ReportDocument + /** 操作后的文档快照。 */ + after: ReportDocument + /** 合并键。 */ + mergeKey?: string + /** 记录时间,用于时间窗合并。 */ + timestamp: number +} + +/** History 配置。 */ +export interface HistoryOptions { + /** 最大保留步数。超出后丢弃最早的记录。默认 100。 */ + limit?: number + /** + * 合并时间窗(毫秒)。 + * + * 相邻且 mergeKey 相同的操作在该时间窗内合并为一步,用于拖拽、 + * 连续输入这类高频操作。默认 500ms。 + */ + mergeWindow?: number +} + +/** 一次事务内累积的变更。 */ +interface TransactionState { + label: string + mergeKey?: string + base: ReportDocument + current: ReportDocument +} + +/** + * 撤销/重做历史。 + * + * 支持两种用法: + * 1. 直接记录:`history.record(before, after, "移动元素")` + * 2. 事务:`history.transaction("批量对齐", () => { ... })` + */ +export class History { + private readonly undoStack: HistoryEntry[] = [] + private readonly redoStack: HistoryEntry[] = [] + private readonly limit: number + private readonly mergeWindow: number + private transactionState: TransactionState | null = null + private pendingBase: ReportDocument | null = null + private lastKnownDocument: ReportDocument | null = null + + constructor(options: HistoryOptions = {}) { + this.limit = options.limit ?? 100 + this.mergeWindow = options.mergeWindow ?? 500 + } + + get canUndo(): boolean { + return this.undoStack.length > 0 + } + + get canRedo(): boolean { + return this.redoStack.length > 0 + } + + get undoDepth(): number { + return this.undoStack.length + } + + get redoDepth(): number { + return this.redoStack.length + } + + /** 撤销栈顶部的操作名,便于 UI 显示「撤销:移动元素」。 */ + peekUndoLabel(): string | undefined { + return this.undoStack[this.undoStack.length - 1]?.label + } + + peekRedoLabel(): string | undefined { + return this.redoStack[this.redoStack.length - 1]?.label + } + + /** + * 记录一次变更。 + * + * 若 before 与 after 内容相同则不记录(避免产生空撤销步)。 + */ + record( + before: ReportDocument, + after: ReportDocument, + label: string, + options: { mergeKey?: string } = {}, + ): boolean { + if (deepEqual(before, after)) return false + + // 事务进行中:只更新事务的 current,最后统一提交 + if (this.transactionState) { + this.transactionState.current = cloneDocument(after) + return true + } + + const now = Date.now() + const entry: HistoryEntry = { + label, + before: cloneDocument(before), + after: cloneDocument(after), + timestamp: now, + } + if (options.mergeKey !== undefined) entry.mergeKey = options.mergeKey + + // 合并连续同类操作 + const top = this.undoStack[this.undoStack.length - 1] + if ( + top && + options.mergeKey !== undefined && + top.mergeKey === options.mergeKey && + now - top.timestamp <= this.mergeWindow + ) { + top.after = entry.after + top.timestamp = now + this.redoStack.length = 0 + return true + } + + this.undoStack.push(entry) + this.redoStack.length = 0 + this.trim() + return true + } + + /** + * 在事务中执行一组操作,整体作为一步撤销。 + * + * 事务中即使记录了多次变更,最终也只产生一条撤销记录。 + * + * 只适用于同步完成的编辑。若编辑要跨越多个事件回调(如拖拽的 + * mousedown → mousemove → mouseup),用 beginTransaction / + * commitTransaction,因为本方法在 fn 返回时就会提交。 + */ + transaction( + label: string, + fn: () => T, + options: { mergeKey?: string } = {}, + ): { value: T; changed: boolean } { + if (this.transactionState) { + // 嵌套事务并入外层,避免产生难以理解的多层撤销 + const value = fn() + return { value, changed: true } + } + + if (!this.beginTransaction(label, options)) { + // 没有可回滚的基准文档:直接执行,视为未变更 + const value = fn() + return { value, changed: false } + } + + try { + const value = fn() + const changed = this.commitTransaction() + return { value, changed } + } catch (cause) { + this.rollbackTransaction() + throw cause + } + } + + /** 是否有事务进行中。 */ + get inTransaction(): boolean { + return this.transactionState !== null + } + + /** + * 开始一个**可跨事件回调**的事务。 + * + * 与 transaction() 的区别:这里只登记基准点并置位,提交时机由调用方 + * 决定。拖拽这类「不知道什么时候结束」的连续编辑必须用这个,否则 + * 中间态会被拆成多条撤销记录(依赖时间窗合并并不可靠——用户拖到 + * 一半停手超过合并窗口再继续,就会留下两条记录)。 + * + * 已有事务进行中时返回 false(嵌套并入外层)。 + */ + beginTransaction(label: string, options: { mergeKey?: string } = {}): boolean { + if (this.transactionState) return false + + const baseline = this.pendingBase ?? this.lastKnownDocument + if (!baseline) return false + + this.transactionState = { + label, + base: cloneDocument(baseline), + current: cloneDocument(baseline), + ...(options.mergeKey !== undefined ? { mergeKey: options.mergeKey } : {}), + } + return true + } + + /** + * 提交事务,整体作为一步撤销。 + * + * 返回是否真的产生了一条记录(内容没变则不记录)。 + */ + commitTransaction(): boolean { + const state = this.transactionState + this.transactionState = null + if (!state) return false + if (deepEqual(state.base, state.current)) return false + + const entry: HistoryEntry = { + label: state.label, + before: cloneDocument(state.base), + after: cloneDocument(state.current), + timestamp: Date.now(), + } + if (state.mergeKey !== undefined) entry.mergeKey = state.mergeKey + this.undoStack.push(entry) + this.redoStack.length = 0 + this.trim() + return true + } + + /** + * 回滚事务,丢弃期间的全部变更。 + * + * 返回应恢复的文档;无事务进行中时返回 null。 + */ + rollbackTransaction(): ReportDocument | null { + const state = this.transactionState + this.transactionState = null + if (!state) return null + return cloneDocument(state.base) + } + + /** + * 设置事务或记录的基准文档。 + * + * 由于 History 不持有当前文档(那是 Invoker 的职责),调用方在每次 + * 操作前通过这个方法告知「变更前的状态」。 + */ + setBase(document: ReportDocument): void { + this.lastKnownDocument = cloneDocument(document) + if (this.transactionState) { + this.transactionState.base = cloneDocument(document) + this.transactionState.current = cloneDocument(document) + } else { + this.pendingBase = cloneDocument(document) + } + } + + /** 事务或记录过程中同步当前状态。 */ + updateCurrent(document: ReportDocument): void { + this.lastKnownDocument = cloneDocument(document) + if (this.transactionState) { + this.transactionState.current = cloneDocument(document) + } + } + + /** 撤销。返回应恢复的文档;无可撤销时返回 null。 */ + undo(): ReportDocument | null { + const entry = this.undoStack.pop() + if (!entry) return null + this.redoStack.push(entry) + this.lastKnownDocument = cloneDocument(entry.before) + this.pendingBase = cloneDocument(entry.before) + return cloneDocument(entry.before) + } + + /** 重做。返回应恢复的文档;无可重做时返回 null。 */ + redo(): ReportDocument | null { + const entry = this.redoStack.pop() + if (!entry) return null + this.undoStack.push(entry) + this.lastKnownDocument = cloneDocument(entry.after) + this.pendingBase = cloneDocument(entry.after) + return cloneDocument(entry.after) + } + + /** 清空历史(加载新文档时调用)。 */ + clear(): void { + this.undoStack.length = 0 + this.redoStack.length = 0 + this.transactionState = null + this.pendingBase = null + this.lastKnownDocument = null + } + + /** 丢弃超出上限的最早记录。 */ + private trim(): void { + while (this.undoStack.length > this.limit) { + this.undoStack.shift() + } + } + + /** 导出历史摘要,便于调试与测试。 */ + describe(): { undo: string[]; redo: string[] } { + return { + undo: this.undoStack.map((entry) => entry.label), + redo: this.redoStack.map((entry) => entry.label), + } + } +} + +/** + * 可撤销的编辑会话。 + * + * 把 Invoker 与 History 组合起来,提供 Designer 直接可用的 API: + * 执行命令 → 自动记录历史 → 可撤销。 + */ +export class EditSession { + private document: ReportDocument + readonly history: History + + constructor(document: ReportDocument, options: HistoryOptions = {}) { + this.document = cloneDocument(document) + this.history = new History(options) + this.history.setBase(this.document) + } + + getDocument(): ReportDocument { + return this.document + } + + /** + * 执行命令。 + * + * 失败时文档不变、不记录历史,直接返回诊断。 + */ + execute( + command: Command, + payload: TPayload, + options: { label?: string; mergeKey?: string } = {}, + ): CommandResult { + const context: CommandContext = { document: this.document } + const result = command.execute(payload, context) + if (!result.document) return result + + const before = this.document + const after = result.document + + if (command.undoable !== false) { + const label = options.label ?? command.type + const mergeKey = options.mergeKey ?? command.mergeKey + this.history.record(before, after, label, { + ...(mergeKey !== undefined ? { mergeKey } :{}), + }) + } + + this.document = after + this.history.updateCurrent(after) + return result + } + + /** + * 以事务方式执行一组编辑,整体作为一步撤销。 + */ + transaction( + label: string, + fn: (session: EditSession) => T, + options: { mergeKey?: string } = {}, + ): { value: T; changed: boolean } { + this.history.setBase(this.document) + return this.history.transaction( + label, + () => fn(this), + options, + ) + } + + /** + * 开始一个**可跨事件回调**的事务。 + * + * 事务期间的编辑用 applyWithinTransaction 执行(只改文档、不单独记历史), + * 结束时用 commitTransaction 整体提交为一步撤销。 + * + * 拖拽必须走这条路径:拖多久、中途停顿多久都不该影响撤销粒度。 + */ + beginTransaction(label: string, options: { mergeKey?: string } = {}): boolean { + this.history.setBase(this.document) + return this.history.beginTransaction(label, options) + } + + /** 提交事务,返回是否产生了一条撤销记录。 */ + commitTransaction(): boolean { + return this.history.commitTransaction() + } + + /** 是否有事务进行中。 */ + get inTransaction(): boolean { + return this.history.inTransaction + } + + /** 撤销。 */ + undo(): boolean { + const restored = this.history.undo() + if (!restored) return false + this.document = restored + return true + } + + /** 重做。 */ + redo(): boolean { + const restored = this.history.redo() + if (!restored) return false + this.document = restored + return true + } + + /** 用新文档替换当前文档并清空历史。 */ + load(document: ReportDocument): void { + this.document = cloneDocument(document) + this.history.clear() + this.history.setBase(this.document) + } + + /** + * 内部使用:让事务内的命令执行走同一条路径。 + * + * 这里直接修改内部文档而不记录历史,由外层 transaction 统一提交。 + */ + applyWithinTransaction( + command: Command, + payload: TPayload, + ): CommandResult { + const context: CommandContext = { document: this.document } + const result = command.execute(payload, context) + if (result.document) { + this.document = result.document + this.history.updateCurrent(this.document) + } + return result + } +} + +/** 创建编辑会话。 */ +export function createEditSession( + document: ReportDocument, + options: HistoryOptions = {}, +): EditSession { + return new EditSession(document, options) +} + +/** 诊断是否为空(便于测试断言)。 */ +export function hasErrorDiagnostics(diagnostics: readonly Diagnostic[]): boolean { + return diagnostics.some((item) => item.severity === "error") +} diff --git a/code/one-designer/packages/report-core/src/diagnostics/diagnostic.ts b/code/one-designer/packages/report-core/src/diagnostics/diagnostic.ts new file mode 100644 index 00000000..97fe285d --- /dev/null +++ b/code/one-designer/packages/report-core/src/diagnostics/diagnostic.ts @@ -0,0 +1,149 @@ +/** + * 诊断构建与收集(对应设计文档 §13)。 + * + * 全流程统一使用 Diagnostic 表达警告和错误,不抛异常做业务控制: + * 数据缺失、表达式错误、字体缺失、插件缺失和分页失败都应尽量返回 + * 可定位的诊断信息,而不是中断整个文档的读取。 + */ +import type { Diagnostic, DiagnosticSeverity } from "../model/common.js" + +/** 诊断错误码。集中定义,便于测试和宿主做本地化映射。 */ +export const DIAGNOSTIC_CODES = { + // 结构校验 + SCHEMA_INVALID: "schema.invalid", + SCHEMA_VERSION_UNKNOWN: "schema.version-unknown", + SCHEMA_VERSION_MISSING: "schema.version-missing", + SCHEMA_MIGRATION_FAILED: "schema.migration-failed", + SCHEMA_REQUIRED_MISSING: "schema.required-missing", + SCHEMA_TYPE_MISMATCH: "schema.type-mismatch", + SCHEMA_ENUM_INVALID: "schema.enum-invalid", + SCHEMA_RANGE_INVALID: "schema.range-invalid", + + // 引用完整性 + REF_DANGLING_DATASET: "ref.dangling-dataset", + REF_DANGLING_DATASOURCE: "ref.dangling-datasource", + REF_DANGLING_STYLE: "ref.dangling-style", + REF_DUPLICATE_ID: "ref.duplicate-id", + REF_CYCLE_DETECTED: "ref.cycle-detected", + + // 元素 + ELEMENT_UNKNOWN_TYPE: "element.unknown-type", + ELEMENT_CHILD_NOT_ALLOWED: "element.child-not-allowed", + ELEMENT_FRAME_INVALID: "element.frame-invalid", + + // 插件 + PLUGIN_MISSING: "plugin.missing", + PLUGIN_VERSION_INCOMPATIBLE: "plugin.version-incompatible", + PLUGIN_ALREADY_REGISTERED: "plugin.already-registered", + + // 数据与表达式 + DATA_DATASET_MISSING: "data.dataset-missing", + EXPRESSION_PARSE_ERROR: "expression.parse-error", + EXPRESSION_EVAL_ERROR: "expression.eval-error", + + // 布局 + LAYOUT_PAGE_OVERFLOW: "layout.page-overflow", + LAYOUT_TEXT_OVERFLOW: "layout.text-overflow", +} as const + +export type DiagnosticCode = + (typeof DIAGNOSTIC_CODES)[keyof typeof DIAGNOSTIC_CODES] + +/** 创建一条诊断。 */ +export function createDiagnostic( + severity: DiagnosticSeverity, + code: string, + message: string, + extra?: { path?: string; elementId?: string }, +): Diagnostic { + const diagnostic: Diagnostic = { severity, code, message } + if (extra?.path !== undefined) diagnostic.path = extra.path + if (extra?.elementId !== undefined) diagnostic.elementId = extra.elementId + return diagnostic +} + +export function info( + code: string, + message: string, + extra?: { path?: string; elementId?: string }, +): Diagnostic { + return createDiagnostic("info", code, message, extra) +} + +export function warning( + code: string, + message: string, + extra?: { path?: string; elementId?: string }, +): Diagnostic { + return createDiagnostic("warning", code, message, extra) +} + +export function error( + code: string, + message: string, + extra?: { path?: string; elementId?: string }, +): Diagnostic { + return createDiagnostic("error", code, message, extra) +} + +/** 诊断收集器。在流程中累积诊断,最后统一交给调用方。 */ +export class DiagnosticBag { + private readonly items: Diagnostic[] = [] + + add(diagnostic: Diagnostic): void { + this.items.push(diagnostic) + } + + info(code: string, message: string, extra?: { path?: string; elementId?: string }): void { + this.items.push(info(code, message, extra)) + } + + warn(code: string, message: string, extra?: { path?: string; elementId?: string }): void { + this.items.push(warning(code, message, extra)) + } + + error(code: string, message: string, extra?: { path?: string; elementId?: string }): void { + this.items.push(error(code, message, extra)) + } + + get size(): number { + return this.items.length + } + + /** 是否包含至少一条错误级诊断。 */ + get hasError(): boolean { + return this.items.some((item) => item.severity === "error") + } + + all(): Diagnostic[] { + return [...this.items] + } + + bySeverity(severity: DiagnosticSeverity): Diagnostic[] { + return this.items.filter((item) => item.severity === severity) + } + + /** 按给定严重级别过滤,返回新的收集器。 */ + filter(severity: DiagnosticSeverity): DiagnosticBag { + const bag = new DiagnosticBag() + for (const item of this.items) { + if (item.severity === severity) bag.add(item) + } + return bag + } +} + +/** 用一组诊断构造收集器。 */ +export function toBag(diagnostics: readonly Diagnostic[]): DiagnosticBag { + const bag = new DiagnosticBag() + for (const diagnostic of diagnostics) bag.add(diagnostic) + return bag +} + +/** 把诊断渲染成便于日志查看的单行文本。 */ +export function formatDiagnostic(diagnostic: Diagnostic): string { + const parts = [`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`] + if (diagnostic.path) parts.push(`path=${diagnostic.path}`) + if (diagnostic.elementId) parts.push(`elementId=${diagnostic.elementId}`) + return parts.join(" ") +} diff --git a/code/one-designer/packages/report-core/src/document/clone.ts b/code/one-designer/packages/report-core/src/document/clone.ts new file mode 100644 index 00000000..cec7bcaf --- /dev/null +++ b/code/one-designer/packages/report-core/src/document/clone.ts @@ -0,0 +1,100 @@ +/** + * 文档克隆(对应设计文档 §4.1)。 + * + * 克隆服务于三类场景: + * - 撤销重做需要保存文档快照(§3.2 明确撤销栈不属于文档,由 Designer 持有)。 + * - 复制粘贴需要深拷贝元素子树。 + * - 预览需要在不污染编辑态的前提下拿到只读副本。 + * + * 所有克隆都保证「纯数据」约束(§3.5):不含函数、DOM 节点和运行时对象。 + */ +import type { ReportDocument } from "../model/report.js" +import type { ReportElement } from "../model/element.js" +import type { ReportSection } from "../model/section.js" +import { deepClone } from "../utils/deep.js" +import { createElementId } from "../utils/id.js" + +/** + * 深拷贝文档。 + * + * 保留原 ID,用于快照。若需要一份「独立的新文档」,请用 create.duplicateDocument。 + */ +export function cloneDocument(document: ReportDocument): ReportDocument { + return deepClone(document) +} + +/** 深拷贝单个元素(保留原 ID)。 */ +export function cloneElement(element: ReportElement): ReportElement { + return deepClone(element) +} + +/** + * 为复制粘贴克隆元素子树。 + * + * 会为整棵子树重新分配 ID,避免粘贴后出现重复 ID。 + * 表格列 ID 同样重新分配。 + */ +export function cloneElementWithNewIds( + element: ReportElement, + generateId: () => string = createElementId, +): ReportElement { + const copy = deepClone(element) + const remap = (node: ReportElement): void => { + node.id = generateId() + if (node.children?.length) node.children.forEach(remap) + const columns = (node as { columns?: { id: string; children?: ReportElement[] }[] }).columns + if (Array.isArray(columns)) { + for (const column of columns) { + column.id = generateId() + if (column.children?.length) column.children.forEach(remap) + } + } + } + remap(copy) + return copy +} + +/** 深拷贝区域。 */ +export function cloneSection(section: ReportSection): ReportSection { + return deepClone(section) +} + +/** + * 克隆区域并重新分配其下所有元素 ID。 + */ +export function cloneSectionWithNewIds( + section: ReportSection, + generateId: () => string = createElementId, +): ReportSection { + const copy = cloneSection(section) + const remap = (elements: ReportElement[]): void => { + for (const element of elements) { + element.id = generateId() + if (element.children?.length) remap(element.children) + const columns = (element as { columns?: { id: string; children?: ReportElement[] }[] }).columns + if (Array.isArray(columns)) { + for (const column of columns) { + column.id = generateId() + if (column.children?.length) remap(column.children) + } + } + } + } + remap(copy.children) + return copy +} + +/** + * 创建文档快照。 + * + * 与 cloneDocument 的差异在于语义:快照是「只读、用于回滚」的副本, + * 调用方不应再修改它。 + */ +export function snapshot(document: ReportDocument): Readonly { + return Object.freeze(cloneDocument(document)) as Readonly +} + +/** 把只读快照还原为可变文档。 */ +export function restore(snapshotValue: Readonly): ReportDocument { + return cloneDocument(snapshotValue as ReportDocument) +} diff --git a/code/one-designer/packages/report-core/src/document/create.ts b/code/one-designer/packages/report-core/src/document/create.ts new file mode 100644 index 00000000..b1176e36 --- /dev/null +++ b/code/one-designer/packages/report-core/src/document/create.ts @@ -0,0 +1,128 @@ +/** + * 文档创建(对应设计文档 §4.1 与 §16 阶段 1)。 + * + * 新建文档必须一步到位地满足规范化要求,因此这里直接产出完整的 + * ReportDocument,而不是半成品结构。 + */ +import type { ReportDocument, ReportMetadata } from "../model/report.js" +import type { PageSettings } from "../model/page.js" +import type { ReportSection, ReportSectionKind } from "../model/section.js" +import type { ReportElement } from "../model/element.js" +import type { StyleDefinition } from "../model/style.js" +import { createDefaultPageSettings } from "../model/page.js" +import { createDefaultSectionOptions } from "../schema/defaults.js" +import { CURRENT_SCHEMA_VERSION } from "../schema/version.js" +import { createDocumentId, createSectionId } from "../utils/id.js" +import { normalize } from "./normalize.js" + +/** 创建文档的选项。 */ +export interface CreateDocumentOptions { + name?: string + id?: string + page?: Partial + metadata?: ReportMetadata + /** 自定义初始区域;缺省为 page-header / detail / page-footer 三个空区域。 */ + sections?: ReportSection[] + styles?: Record +} + +/** 新建一份空白报表文档。 */ +export function createDocument(options: CreateDocumentOptions = {}): ReportDocument { + const name = options.name ?? "未命名报表" + const page = mergePage(options.page) + + const document: ReportDocument = { + schemaVersion: CURRENT_SCHEMA_VERSION, + id: options.id ?? createDocumentId(), + name, + page, + styles: options.styles ?? {}, + dataSources: {}, + datasets: {}, + parameters: {}, + sections: options.sections ?? createDefaultSections(), + } + + if (options.metadata) { + document.metadata = { ...options.metadata } + } + + return normalize(document) +} + +function mergePage(partial: Partial | undefined): PageSettings { + const base = createDefaultPageSettings() + if (!partial) return base + const out: PageSettings = { + paper: partial.paper ?? base.paper, + orientation: partial.orientation ?? base.orientation, + margin: { ...base.margin, ...(partial.margin ?? {}) }, + } + if (partial.customSize) out.customSize = { ...partial.customSize } + if (partial.background !== undefined) out.background = partial.background + return out +} + +/** 创建 V1 的三个默认区域。 */ +export function createDefaultSections(): ReportSection[] { + const kinds: ReportSectionKind[] = ["page-header", "detail", "page-footer"] + return kinds.map((kind) => { + const section: ReportSection = { id: createSectionId(), kind, children: [] } + const options = createDefaultSectionOptions(kind) + if (options !== undefined) section.options = options + return section + }) +} + +/** + * 从已有文档派生一份新文档。 + * + * 会重新生成文档 ID,避免同一宿主里出现两份同 ID 文档。 + * 跨文档引用(样式、数据集)保持原样,因为它们随文档一起被复制。 + */ +export function duplicateDocument( + document: ReportDocument, + options: { name?: string } = {}, +): ReportDocument { + const copy = normalize(deepCopyDocument(document)) + copy.id = createDocumentId() + copy.name = options.name ?? `${document.name} 副本` + if (copy.metadata) { + copy.metadata.createdAt = new Date().toISOString() + delete copy.metadata.updatedAt + } + return copy +} + +/** 文档的深拷贝(纯数据,直接走 JSON 通路最稳妥)。 */ +function deepCopyDocument(document: ReportDocument): ReportDocument { + return JSON.parse(JSON.stringify(document)) as ReportDocument +} + +/** 判断文档是否为空(没有任何元素、数据源、数据集和参数)。 */ +export function isDocumentEmpty(document: ReportDocument): boolean { + if (document.sections.some((section) => section.children.length > 0)) return false + if (Object.keys(document.dataSources).length > 0) return false + if (Object.keys(document.datasets).length > 0) return false + if (Object.keys(document.parameters).length > 0) return false + return true +} + +/** 统计文档中的元素总数(含嵌套)。 */ +export function countElements(document: ReportDocument): number { + let count = 0 + const walk = (elements: readonly ReportElement[]): void => { + for (const element of elements) { + count += 1 + if (element.children?.length) walk(element.children) + const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns + if (Array.isArray(columns)) { + for (const column of columns) { + if (column.children?.length) walk(column.children) + } + } + } + } + for (const section of document.sections) walk(section.children) + return count +} diff --git a/code/one-designer/packages/report-core/src/document/normalize.ts b/code/one-designer/packages/report-core/src/document/normalize.ts new file mode 100644 index 00000000..e5261de8 --- /dev/null +++ b/code/one-designer/packages/report-core/src/document/normalize.ts @@ -0,0 +1,180 @@ +/** + * 文档规范化(对应设计文档 §12 的 normalize 阶段)。 + * + * 规范化保证「新建文档」和「读取旧文档」得到结构一致的结果: + * 补齐缺省字段、统一 ID 引用、丢弃非法值。规范化不做业务校验, + * 校验由 schema/validation 负责,两者职责分离。 + */ +import type { ReportDocument, ReportPluginReference } from "../model/report.js" +import type { ReportSection } from "../model/section.js" +import type { ReportElement } from "../model/element.js" +import type { StyleDefinition } from "../model/style.js" +import type { DataSourceDefinition, DatasetDefinition } from "../model/datasource.js" +import type { ReportParameter } from "../model/parameter.js" +import { + normalizeElement, + normalizeSection, + createDefaultPageSettings, +} from "../schema/defaults.js" +import { CURRENT_SCHEMA_VERSION } from "../schema/version.js" +import { createSectionId } from "../utils/id.js" + +/** + * 规范化文档。 + * + * 输入可以是任意 unknown(通常来自反序列化后的 JSON),输出一定是 + * 结构完整的 ReportDocument。缺字段补默认,多余字段丢弃。 + */ +export function normalize(document: ReportDocument): ReportDocument { + const sections = normalizeSections(document.sections) + + const out: ReportDocument = { + schemaVersion: CURRENT_SCHEMA_VERSION, + id: document.id, + name: typeof document.name === "string" && document.name.length > 0 + ? document.name + : "未命名报表", + page: normalizePage(document.page), + styles: normalizeStyles(document.styles), + dataSources: normalizeDataSources(document.dataSources), + datasets: normalizeDatasets(document.datasets), + parameters: normalizeParameters(document.parameters), + sections, + } + + const plugins = normalizePlugins(document.plugins) + if (plugins) out.plugins = plugins + if (document.metadata) out.metadata = { ...document.metadata } + + return out +} + +function normalizePage(page: ReportDocument["page"]): ReportDocument["page"] { + const fallback = createDefaultPageSettings() + if (!page) return fallback + const out: ReportDocument["page"] = { + paper: page.paper ?? fallback.paper, + orientation: page.orientation ?? fallback.orientation, + margin: { + top: page.margin?.top ?? fallback.margin.top, + right: page.margin?.right ?? fallback.margin.right, + bottom: page.margin?.bottom ?? fallback.margin.bottom, + left: page.margin?.left ?? fallback.margin.left, + }, + } + if (page.customSize) { + out.customSize = { + width: page.customSize.width, + height: page.customSize.height, + } + } + if (page.background !== undefined) out.background = page.background + return out +} + +function normalizeStyles( + styles: Record | undefined, +): Record { + const out: Record = {} + for (const [id, style] of Object.entries(styles ?? {})) { + if (!style || typeof style !== "object") continue + // 以字典键为准,保证 style.id 与键一致,避免出现两处不一致 + out[id] = { ...style, id } + } + return out +} + +function normalizeDataSources( + sources: Record | undefined, +): Record { + const out: Record = {} + for (const [id, source] of Object.entries(sources ?? {})) { + if (!source || typeof source !== "object") continue + const item: DataSourceDefinition = { id, type: source.type ?? "unknown" } + if (source.config !== undefined) item.config = source.config + if (source.connectionRef !== undefined) item.connectionRef = source.connectionRef + out[id] = item + } + return out +} + +function normalizeDatasets( + datasets: Record | undefined, +): Record { + const out: Record = {} + for (const [id, dataset] of Object.entries(datasets ?? {})) { + if (!dataset || typeof dataset !== "object") continue + const item: DatasetDefinition = { id } + if (dataset.sourceId !== undefined) item.sourceId = dataset.sourceId + if (dataset.fields !== undefined) item.fields = dataset.fields.map((f) => ({ ...f })) + if (dataset.query !== undefined) item.query = dataset.query + if (dataset.config !== undefined) item.config = dataset.config + out[id] = item + } + return out +} + +function normalizeParameters( + parameters: Record | undefined, +): Record { + const out: Record = {} + for (const [id, parameter] of Object.entries(parameters ?? {})) { + if (!parameter || typeof parameter !== "object") continue + // 参数名缺省时回落到 id,保证表达式始终可引用 + const name = typeof parameter.name === "string" && parameter.name.length > 0 + ? parameter.name + : id + out[id] = { ...parameter, id, name } + } + return out +} + +function normalizePlugins( + plugins: ReportPluginReference[] | undefined, +): ReportPluginReference[] | undefined { + if (!Array.isArray(plugins) || plugins.length === 0) return undefined + const seen = new Set() + const out: ReportPluginReference[] = [] + for (const plugin of plugins) { + if (!plugin?.id || seen.has(plugin.id)) continue + seen.add(plugin.id) + const item: ReportPluginReference = { id: plugin.id, version: plugin.version ?? "*" } + if (plugin.config !== undefined) item.config = plugin.config + out.push(item) + } + return out.length > 0 ? out : undefined +} + +/** + * 规范化区域列表。 + * + * 保证 page-header / detail / page-footer 三个 V1 区域一定存在, + * 且每种至多一个,避免 Designer 侧到处判空。 + */ +export function normalizeSections( + sections: ReportSection[] | undefined, +): ReportSection[] { + const list = Array.isArray(sections) ? sections.map(normalizeSection) : [] + + const singleKinds: ReportSection["kind"][] = ["page-header", "detail", "page-footer"] + const out: ReportSection[] = [] + + for (const kind of singleKinds) { + const existing = list.find((section) => section.kind === kind) + if (existing) { + out.push(existing) + } else { + out.push({ id: createSectionId(), kind, children: [] }) + } + } + // 其余区域(report-header / group-* / summary)按原顺序附在后面 + for (const section of list) { + if (!singleKinds.includes(section.kind)) out.push(section) + } + return out +} + +/** 规范化元素树。 */ +export function normalizeElements(elements: ReportElement[] | undefined): ReportElement[] { + return (elements ?? []).map(normalizeElement) +} diff --git a/code/one-designer/packages/report-core/src/index.ts b/code/one-designer/packages/report-core/src/index.ts new file mode 100644 index 00000000..1eb5b25c --- /dev/null +++ b/code/one-designer/packages/report-core/src/index.ts @@ -0,0 +1,298 @@ +/** + * @report/core 公共入口(对应设计文档 §4.1、§5)。 + * + * 本包是纯 TypeScript,无 Vue / React / DOM / 浏览器 API 依赖(§3.1), + * 可在 Browser、Node.js、Electron、Server 和 CLI 中复用。 + * + * 导出组织原则: + * - 模型与类型从 model/ 导出。 + * - 行为函数按领域分组导出,命名保持动词开头。 + * - 内部工具(deep / id)只导出稳定接口,供 Designer 复用。 + */ + +// --------------------------------------------------------------------------- +// 模型 +// --------------------------------------------------------------------------- + +export type { + Rect, + BoxSpacing, + PageMargin, + BorderStyle, + BorderSide, + BorderLineStyle, + HorizontalAlign, + VerticalAlign, + ColorValue, + ColorConfig, + Diagnostic, + DiagnosticSeverity, +} from "./model/common.js" + +export type { + PaperKind, + PageOrientation, + CustomPageSize, + PageSettings, + ResolvedPageSize, +} from "./model/page.js" +export { + PAPER_SIZES_MM, + DEFAULT_PAGE_MARGIN, + createDefaultPageSettings, + resolvePageSize, + resolveContentBox, +} from "./model/page.js" + +export type { + StyleDefinition, + ResolvedStyle, + StyleResolution, +} from "./model/style.js" +export { + DEFAULT_RESOLVED_STYLE, + mergeStyleOverride, + resolveStyleChain, +} from "./model/style.js" + +export type { ReportElement, ValueBinding, BuiltinElementType } from "./model/element.js" +export { + BUILTIN_ELEMENT_TYPES, + CONTAINER_ELEMENT_TYPES, + isBuiltinElementType, + canHaveChildren, + createTextElement, + createRectElement, + createLineElement, + createImageElement, + createContainerElement, +} from "./model/element.js" + +export type { + TableElement, + TableColumn, + RowHeightMode, +} from "./model/table.js" +export { + isTableElement, + measureTableWidth, + createTableElement, +} from "./model/table.js" + +export type { + ReportSection, + ReportSectionKind, + ReportSectionOptions, +} from "./model/section.js" +export { + V1_SECTION_KINDS, + GROUP_SECTION_KINDS, + RESERVED_SECTION_KINDS, + isV1SectionKind, + isGroupSectionKind, + createSection, +} from "./model/section.js" + +export type { + DataSourceDefinition, + DatasetDefinition, + DatasetField, + DatasetFieldType, +} from "./model/datasource.js" +export { findSensitiveKeys } from "./model/datasource.js" + +export type { + ReportParameter, + ParameterOption, + ParameterValueType, +} from "./model/parameter.js" +export { isValidParameterName } from "./model/parameter.js" + +export type { RenderContext, DataProvider } from "./model/runtime.js" +export { createEmptyRenderContext } from "./model/runtime.js" + +export type { + ReportDocument, + ReportMetadata, + ReportPluginReference, +} from "./model/report.js" +export { + findSectionsByKind, + findFirstSection, + collectElementIds, +} from "./model/report.js" + +// --------------------------------------------------------------------------- +// Schema:版本、默认值与校验 +// --------------------------------------------------------------------------- + +export type { Migration } from "./schema/version.js" +export { + CURRENT_SCHEMA_VERSION, + MIN_SCHEMA_VERSION, + MIGRATIONS, + planMigrations, + isValidSchemaVersion, +} from "./schema/version.js" + +export { + DEFAULT_ELEMENT_SIZE, + FALLBACK_ELEMENT_SIZE, + getDefaultElementSize, + createDefaultFrame, + createDefaultSectionOptions, + createDefaultTableColumn, + createDefaultTable, + createDefaultMetadata, + normalizeElement, + normalizeRect, + normalizeColumn, + normalizeSection, + createEmptyDocumentSkeleton, +} from "./schema/defaults.js" +export type { EmptyDocumentSkeleton } from "./schema/defaults.js" + +export { + validateStructure, + validateDocument, + detectStyleCycles, +} from "./schema/validation.js" + +// --------------------------------------------------------------------------- +// 文档:创建、克隆、规范化 +// --------------------------------------------------------------------------- + +export type { CreateDocumentOptions } from "./document/create.js" +export { + createDocument, + createDefaultSections, + duplicateDocument, + isDocumentEmpty, + countElements, +} from "./document/create.js" + +export { + cloneDocument, + cloneElement, + cloneElementWithNewIds, + cloneSection, + cloneSectionWithNewIds, + snapshot, + restore, +} from "./document/clone.js" + +export { normalize, normalizeSections, normalizeElements } from "./document/normalize.js" + +// --------------------------------------------------------------------------- +// 序列化 +// --------------------------------------------------------------------------- + +export type { SerializeOptions } from "./serialization/serialize.js" +export { serialize, serializeValue, toPlainObject } from "./serialization/serialize.js" + +export type { MigrateResult } from "./serialization/migrate.js" +export { migrate, needsMigration } from "./serialization/migrate.js" + +export type { DeserializeOptions, DeserializeResult } from "./serialization/deserialize.js" +export { + deserialize, + deserializeValue, + deserializeLeniently, + deserializeStrictly, + tryDeserialize, + summarizeDiagnostics, +} from "./serialization/deserialize.js" + +// --------------------------------------------------------------------------- +// 命令与撤销重做 +// --------------------------------------------------------------------------- + +export type { + Command, + CommandContext, + CommandResult, +} from "./command/command.js" +export { + createCommand, + defineCommand, + success, + failure, + isUndoable, + CommandInvoker, + createInvoker, +} from "./command/command.js" + +export type { HistoryEntry, HistoryOptions } from "./command/history.js" +export { + History, + EditSession, + createEditSession, + hasErrorDiagnostics, +} from "./command/history.js" + +// --------------------------------------------------------------------------- +// 插件 +// --------------------------------------------------------------------------- + +export type { + ReportPlugin, + ElementPlugin, + DataSourcePlugin, + ExporterPlugin, + PluginContext, + PluginKind, + MissingPluginInfo, +} from "./plugin/plugin.js" +export { isVersionCompatible, describeDataSourcePlugin } from "./plugin/plugin.js" + +export type { RegisterResult } from "./plugin/registry.js" +export { + PluginRegistry, + createPluginRegistry, + reportRegisterResult, +} from "./plugin/registry.js" + +// --------------------------------------------------------------------------- +// 诊断 +// --------------------------------------------------------------------------- + +export { + DIAGNOSTIC_CODES, + createDiagnostic, + info, + warning, + error, + DiagnosticBag, + toBag, + formatDiagnostic, +} from "./diagnostics/diagnostic.js" +export type { DiagnosticCode } from "./diagnostics/diagnostic.js" + +// --------------------------------------------------------------------------- +// 工具 +// --------------------------------------------------------------------------- + +export { + createId, + createElementId, + createSectionId, + createDocumentId, + createDatasetId, + createDataSourceId, + createParameterId, + createColumnId, + createStyleId, + isValidId, + createUniqueId, +} from "./utils/id.js" +export type { IdOptions } from "./utils/id.js" + +export { + isPlainObject, + isPlainData, + deepClone, + deepEqual, + sortKeysDeep, + omitUndefined, + deepMerge, +} from "./utils/deep.js" diff --git a/code/one-designer/packages/report-core/src/model/common.ts b/code/one-designer/packages/report-core/src/model/common.ts new file mode 100644 index 00000000..d5ed69a5 --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/common.ts @@ -0,0 +1,88 @@ +/** + * 基础公共类型(对应设计文档 §6.2 / §7 / §13)。 + * + * 本文件是整个 core 的最底层,不依赖任何其他模块。 + */ + +/** 矩形区域。文档内部长度单位统一为 mm(§7)。 */ +export interface Rect { + x: number + y: number + width: number + height: number +} + +/** 四边尺寸,用于页边距、内边距等。单位 mm。 */ +export interface BoxSpacing { + top: number + right: number + bottom: number + left: number +} + +/** 页面边距(§7)。 */ +export type PageMargin = BoxSpacing + +/** 边框线型。 */ +export type BorderLineStyle = "solid" | "dashed" | "dotted" | "none" + +/** 单条边框。 */ +export interface BorderSide { + width: number + color: string + style?: BorderLineStyle +} + +/** + * 边框定义。既支持四边统一写法,也支持逐边覆盖。 + */ +export interface BorderStyle { + /** 四边统一宽度;被 sides 中的同边配置覆盖 */ + width?: number + /** 四边统一颜色;被 sides 中的同边配置覆盖 */ + color?: string + /** 四边统一线型 */ + style?: BorderLineStyle + /** 逐边覆盖,优先级高于统一配置 */ + sides?: { + top?: BorderSide + right?: BorderSide + bottom?: BorderSide + left?: BorderSide + } +} + +/** 水平对齐。 */ +export type HorizontalAlign = "left" | "center" | "right" + +/** 垂直对齐。 */ +export type VerticalAlign = "top" | "middle" | "bottom" + +/** + * 颜色字符串。 + * + * 文档层允许 #rgb / #rrggbb / #rrggbbaa / rgb() / rgba() / hsl() / oklch() + * 以及 "transparent"。UI 层的设计令牌(§14.4)不进入文档,文档保存的是 + * 解析后的具体色值,保证跨 Renderer 输出一致。 + */ +export type ColorValue = string + +/** 通用诊断严重级别(§13)。 */ +export type DiagnosticSeverity = "info" | "warning" | "error" + +/** 统一的诊断对象(§13)。 */ +export interface Diagnostic { + severity: DiagnosticSeverity + code: string + message: string + /** 字段路径,使用 JSON Pointer 风格,如 /sections/0/children/1/frame/width */ + path?: string + /** 相关元素 ID */ + elementId?: string +} + +/** 颜色配置,保留未来扩展渐变等能力的空间。 */ +export interface ColorConfig { + type: "solid" | "none" + value?: ColorValue +} diff --git a/code/one-designer/packages/report-core/src/model/dataset.ts b/code/one-designer/packages/report-core/src/model/dataset.ts new file mode 100644 index 00000000..9feed2a5 --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/dataset.ts @@ -0,0 +1,11 @@ +/** + * 数据集定义(对应设计文档 §8)。 + * + * 从 datasource.ts 拆出,保持「数据源定义」与「数据集定义」职责清晰: + * 数据源描述“连到哪里”,数据集描述“取哪些字段、以何种查询取”。 + */ +export type { + DatasetDefinition, + DatasetField, + DatasetFieldType, +} from "./datasource.js" diff --git a/code/one-designer/packages/report-core/src/model/datasource.ts b/code/one-designer/packages/report-core/src/model/datasource.ts new file mode 100644 index 00000000..a18cac74 --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/datasource.ts @@ -0,0 +1,87 @@ +/** + * 数据源定义(对应设计文档 §8)。 + * + * 关键约束:报表文档只保存数据源定义,不保存连接、Token 和本次查询结果。 + * config 只保存非敏感的设计配置;连接密码、Token 等敏感信息必须通过 + * connectionRef 或宿主运行时注入。 + */ + +/** 数据源定义。 */ +export interface DataSourceDefinition { + id: string + /** 数据源类型标识,由 DataSourcePlugin 提供,如 "rest"、"sql"、"static"。 */ + type: string + /** 非敏感的设计期配置。禁止写入密码和 Token。 */ + config?: Record + /** + * 指向宿主应用中已配置的连接。 + * + * Core 不解析该值,只做透传,由宿主的 DataProvider 负责解析。 + */ + connectionRef?: string +} + +/** 数据集字段。 */ +export interface DatasetField { + name: string + /** 字段类型,用于表达式求值和格式化。 */ + type?: DatasetFieldType + /** 展示名。 */ + label?: string +} + +/** 字段类型。 */ +export type DatasetFieldType = + | "string" + | "number" + | "boolean" + | "date" + | "datetime" + | "object" + | "array" + | "any" + +/** 数据集定义。 */ +export interface DatasetDefinition { + id: string + /** 所属数据源 ID。缺省表示使用宿主注入的默认数据源。 */ + sourceId?: string + fields?: DatasetField[] + /** + * 查询语句 / 请求描述。 + * + * Core 不解析其语法,只做透传,由宿主的 DataProvider 解释, + * 从而避免 Core 绑定任何具体的数据访问技术。 + */ + query?: string + /** 数据集级的设计期配置。 */ + config?: Record +} + +/** + * 判断数据源定义中是否含有疑似敏感字段。 + * + * 用于序列化前的体检和 Designer 的提示:文档不允许出现明文凭据(§3.4)。 + */ +export function findSensitiveKeys(source: DataSourceDefinition): string[] { + const SENSITIVE = /(password|passwd|secret|token|apikey|api_key|credential|privatekey)/i + const hits: string[] = [] + const walk = (value: unknown, path: string): void => { + if (value === null || typeof value !== "object") return + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${path}/${index}`)) + return + } + for (const [key, item] of Object.entries(value as Record)) { + const next = `${path}/${key}` + if (SENSITIVE.test(key)) hits.push(next) + walk(item, next) + } + } + walk(source.config, "/config") + if (source.connectionRef !== undefined && SENSITIVE.test(source.connectionRef)) { + // connectionRef 本身是引用而非凭据,仅当其形如明文键值对时提示 + hits.push("/connectionRef") + } + return hits +} diff --git a/code/one-designer/packages/report-core/src/model/element.ts b/code/one-designer/packages/report-core/src/model/element.ts new file mode 100644 index 00000000..9cf52b1a --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/element.ts @@ -0,0 +1,107 @@ +/** + * 元素模型(对应设计文档 §6.2)。 + * + * 报表采用「文档 + 区域 + 元素树」模型,元素可嵌套(container / table)。 + * 元素坐标 frame 相对于其父容器;顶层元素相对于所在区域的内容区。 + */ +import type { Rect } from "./common.js" + +/** 值绑定(§6.2)。 */ +export interface ValueBinding { + /** 字段路径,如 "amount" 或 "customer.name"。 */ + field?: string + /** 受限表达式,如 "row.price * row.quantity"。 */ + expression?: string + /** 格式化模式,如 "YYYY-MM-DD"、"#,##0.00"。 */ + format?: string +} + +/** 元素基类。 */ +export interface ReportElement { + id: string + /** 元素类型。内置类型见 BUILTIN_ELEMENT_TYPES,插件可用自定义 type。 */ + type: string + + frame: Rect + styleId?: string + + binding?: ValueBinding + /** 元素专属属性,由内置实现或插件 Schema 解释。 */ + props?: Record + children?: ReportElement[] + /** 元素级样式覆盖,优先级高于 styleId。 */ + style?: Record + /** 人类可读的名称,用于图层面板。 */ + name?: string + /** 是否锁定,禁止在 Designer 中直接操作。 */ + locked?: boolean + /** 是否隐藏。隐藏元素不参与布局输出。 */ + hidden?: boolean +} + +/** 内置元素类型(§6.2)。 */ +export const BUILTIN_ELEMENT_TYPES = [ + "text", + "image", + "line", + "rect", + "container", + "table", +] as const + +export type BuiltinElementType = (typeof BUILTIN_ELEMENT_TYPES)[number] + +/** 判断是否为内置元素类型。 */ +export function isBuiltinElementType(type: string): type is BuiltinElementType { + return (BUILTIN_ELEMENT_TYPES as readonly string[]).includes(type) +} + +/** + * 允许包含子元素的类型。 + * + * line / image / text 是叶子节点;container / table 可嵌套。 + * 插件的容器能力由其 ElementPlugin 自行声明。 + */ +export const CONTAINER_ELEMENT_TYPES: ReadonlySet = new Set([ + "container", + "table", +]) + +/** 判断某类型是否允许拥有 children。 */ +export function canHaveChildren(type: string, pluginContainers?: ReadonlySet): boolean { + if (CONTAINER_ELEMENT_TYPES.has(type)) return true + return pluginContainers?.has(type) ?? false +} + +/** 创建文本元素。 */ +export function createTextElement( + id: string, + frame: Rect, + text = "文本", +): ReportElement { + return { id, type: "text", frame, props: { text } } +} + +/** 创建矩形元素。 */ +export function createRectElement(id: string, frame: Rect): ReportElement { + return { id, type: "rect", frame } +} + +/** 创建线条元素。 */ +export function createLineElement(id: string, frame: Rect): ReportElement { + return { id, type: "line", frame, props: { direction: "horizontal" } } +} + +/** 创建图片元素。 */ +export function createImageElement(id: string, frame: Rect, src = ""): ReportElement { + return { id, type: "image", frame, props: { src, fit: "contain" } } +} + +/** 创建容器元素。 */ +export function createContainerElement( + id: string, + frame: Rect, + children: ReportElement[] = [], +): ReportElement { + return { id, type: "container", frame, children } +} diff --git a/code/one-designer/packages/report-core/src/model/page.ts b/code/one-designer/packages/report-core/src/model/page.ts new file mode 100644 index 00000000..f72cbaa3 --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/page.ts @@ -0,0 +1,107 @@ +/** + * 页面模型(对应设计文档 §7)。 + * + * 单位约定:文档内部统一使用 mm;px 只用于 Designer 显示层, + * pt 只在 PDF 等输出边界转换。因此本文件出现的所有长度均为 mm。 + */ +import type { PageMargin } from "./common.js" + +/** 纸张类型。 */ +export type PaperKind = "A4" | "A5" | "custom" + +/** 纸张方向。 */ +export type PageOrientation = "portrait" | "landscape" + +/** 自定义纸张尺寸。 */ +export interface CustomPageSize { + width: number + height: number +} + +/** 页面设置。 */ +export interface PageSettings { + paper: PaperKind + orientation: PageOrientation + customSize?: CustomPageSize + margin: PageMargin + background?: string +} + +/** + * 标准纸张的纵向尺寸(mm)。 + * + * 这里只保存纵向基准值,横向由 resolvePageSize 交换宽高得到, + * 避免同一纸张出现两份可能不一致的数据。 + */ +export const PAPER_SIZES_MM: Readonly, CustomPageSize>> = + Object.freeze({ + A4: Object.freeze({ width: 210, height: 297 }), + A5: Object.freeze({ width: 148, height: 210 }), + }) + +/** 默认页边距(mm)。 */ +export const DEFAULT_PAGE_MARGIN: PageMargin = Object.freeze({ + top: 15, + right: 15, + bottom: 15, + left: 15, +}) + +/** 默认页面设置。 */ +export function createDefaultPageSettings(): PageSettings { + return { + paper: "A4", + orientation: "portrait", + margin: { ...DEFAULT_PAGE_MARGIN }, + } +} + +/** 解析后的页面尺寸(mm)。 */ +export interface ResolvedPageSize { + width: number + height: number +} + +/** + * 把 paper + orientation 解析为最终的 width / height(§7)。 + * + * 这是 Layout 阶段的职责,放在 core 是为了让 Designer 预览和 + * Renderer 都能拿到同一套换算结果。 + */ +export function resolvePageSize(page: PageSettings): ResolvedPageSize { + const base = resolveBaseSize(page) + return page.orientation === "landscape" + ? { width: base.height, height: base.width } + : { width: base.width, height: base.height } +} + +function resolveBaseSize(page: PageSettings): ResolvedPageSize { + if (page.paper === "custom") { + return { + width: page.customSize?.width ?? PAPER_SIZES_MM.A4.width, + height: page.customSize?.height ?? PAPER_SIZES_MM.A4.height, + } + } + const size = PAPER_SIZES_MM[page.paper] + return { width: size.width, height: size.height } +} + +/** + * 纸张内容区尺寸(扣除页边距后,mm)。 + * + * 分页计算的可排版高度即来自这里。 + */ +export function resolveContentBox(page: PageSettings): { + width: number + height: number + x: number + y: number +} { + const size = resolvePageSize(page) + return { + x: page.margin.left, + y: page.margin.top, + width: Math.max(0, size.width - page.margin.left - page.margin.right), + height: Math.max(0, size.height - page.margin.top - page.margin.bottom), + } +} diff --git a/code/one-designer/packages/report-core/src/model/parameter.ts b/code/one-designer/packages/report-core/src/model/parameter.ts new file mode 100644 index 00000000..590c771b --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/parameter.ts @@ -0,0 +1,39 @@ +/** + * 报表参数契约(对应设计文档 §8)。 + * + * 参数是设计期声明、运行期注入的输入。文档只保存声明, + * 实际取值由 RenderContext.parameters 提供。 + */ +import type { DatasetFieldType } from "./datasource.js" + +/** 参数值类型。 */ +export type ParameterValueType = Exclude + +/** 报表参数声明。 */ +export interface ReportParameter { + id: string + /** 参数名,表达式通过 params. 访问。 */ + name: string + type?: ParameterValueType + /** 展示名。 */ + label?: string + /** 默认值。必须是纯数据。 */ + defaultValue?: unknown + /** 是否必填。 */ + required?: boolean + /** 可选项,用于枚举型参数。 */ + options?: ParameterOption[] + /** 帮助文本。 */ + description?: string +} + +/** 枚举型参数的候选项。 */ +export interface ParameterOption { + label: string + value: string | number | boolean +} + +/** 判断参数名是否可用于表达式(合法的标识符)。 */ +export function isValidParameterName(name: string): boolean { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) +} diff --git a/code/one-designer/packages/report-core/src/model/report.ts b/code/one-designer/packages/report-core/src/model/report.ts new file mode 100644 index 00000000..5123cfcc --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/report.ts @@ -0,0 +1,90 @@ +/** + * 报表文档根模型(对应设计文档 §6)。 + * + * 原则(§3.2):ReportDocument 只保存可序列化的报表定义,不保存 + * 当前选中项、缩放比例、滚动位置、辅助线、撤销栈,以及已加载的数据 + * 和网络连接。任何编辑器状态都必须留在 Designer 侧。 + */ +import type { PageSettings } from "./page.js" +import type { ReportSection } from "./section.js" +import type { StyleDefinition } from "./style.js" +import type { DataSourceDefinition } from "./datasource.js" +import type { DatasetDefinition } from "./datasource.js" +import type { ReportParameter } from "./parameter.js" + +/** 插件引用。只保存 id、version 和配置(§11)。 */ +export interface ReportPluginReference { + id: string + version: string + config?: Record +} + +/** 文档元信息。 */ +export interface ReportMetadata { + title?: string + description?: string + author?: string + /** ISO 8601 时间字符串。 */ + createdAt?: string + updatedAt?: string + /** 宿主自定义标签。 */ + tags?: string[] + [key: string]: unknown +} + +/** 报表文档。 */ +export interface ReportDocument { + schemaVersion: number + + id: string + name: string + + page: PageSettings + + styles: Record + + dataSources: Record + datasets: Record + parameters: Record + + sections: ReportSection[] + + plugins?: ReportPluginReference[] + metadata?: ReportMetadata +} + +/** 按区域种类查找区域。 */ +export function findSectionsByKind( + document: ReportDocument, + kind: ReportSection["kind"], +): ReportSection[] { + return document.sections.filter((section) => section.kind === kind) +} + +/** 取第一个匹配的区域。 */ +export function findFirstSection( + document: ReportDocument, + kind: ReportSection["kind"], +): ReportSection | undefined { + return document.sections.find((section) => section.kind === kind) +} + +/** 收集文档中所有元素的 ID(含嵌套)。 */ +export function collectElementIds(document: ReportDocument): Set { + const ids = new Set() + const walk = (elements: readonly import("./element.js").ReportElement[]): void => { + for (const element of elements) { + ids.add(element.id) + if (element.children?.length) walk(element.children) + const columns = (element as { columns?: readonly import("./table.js").TableColumn[] }) + .columns + if (columns?.length) { + for (const column of columns) { + if (column.children?.length) walk(column.children) + } + } + } + } + for (const section of document.sections) walk(section.children) + return ids +} diff --git a/code/one-designer/packages/report-core/src/model/runtime.ts b/code/one-designer/packages/report-core/src/model/runtime.ts new file mode 100644 index 00000000..d775282b --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/runtime.ts @@ -0,0 +1,43 @@ +/** + * 运行时数据契约(对应设计文档 §8)。 + * + * 报表文档只保存数据源定义,不保存连接、Token 和本次查询结果。 + * 运行时数据通过 RenderContext 注入,取数能力通过 DataProvider 注入。 + * + * 放在 core 而非 layout:这两个契约是**跨包**的—— + * Designer 需要构造 RenderContext 做预览,Layout 需要消费它, + * 若定义在 layout 则 Designer 必须依赖 layout 才能构造上下文。 + */ +import type { DatasetDefinition } from "./datasource.js" + +/** 渲染上下文(§8)。 */ +export interface RenderContext { + /** + * 数据集数据:datasetId → 行数组。 + * + * 宿主预先取数后注入;Core 与 Layout 都不主动访问网络或数据库。 + */ + data: Record>> + /** 报表参数。 */ + parameters: Record + locale?: string + timezone?: string +} + +/** + * 数据提供器(§8)。 + * + * 由宿主实现。当 RenderContext 中缺少某数据集时按需调用, + * 使 Core/Layout 保持与具体数据访问技术无关。 + */ +export interface DataProvider { + getDataset( + dataset: DatasetDefinition, + context: RenderContext, + ): Promise>> +} + +/** 创建空的渲染上下文。 */ +export function createEmptyRenderContext(): RenderContext { + return { data: {}, parameters: {} } +} diff --git a/code/one-designer/packages/report-core/src/model/section.ts b/code/one-designer/packages/report-core/src/model/section.ts new file mode 100644 index 00000000..c72245de --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/section.ts @@ -0,0 +1,94 @@ +/** + * 报表区域模型(对应设计文档 §6.1)。 + * + * V1 先实现 page-header、detail 和 page-footer,其他区域只保留模型扩展点。 + */ +import type { ReportElement } from "./element.js" + +/** 区域种类。 */ +export type ReportSectionKind = + | "report-header" + | "page-header" + | "detail" + | "group-header" + | "group-footer" + | "summary" + | "page-footer" + +/** V1 实际实现渲染的区域。 */ +export const V1_SECTION_KINDS: readonly ReportSectionKind[] = [ + "page-header", + "detail", + "page-footer", +] + +/** 分组相关区域。 */ +export const GROUP_SECTION_KINDS: readonly ReportSectionKind[] = [ + "group-header", + "group-footer", +] + +/** 仅保留模型扩展点、尚未实现渲染的区域。 */ +export const RESERVED_SECTION_KINDS: readonly ReportSectionKind[] = [ + "report-header", + "summary", +] + +/** 判断是否为分组区域。 */ +export function isGroupSectionKind(kind: ReportSectionKind): boolean { + return GROUP_SECTION_KINDS.includes(kind) +} + +/** 区域级选项。 */ +export interface ReportSectionOptions { + /** 每页重复(页眉页脚用)。 */ + repeatOnEachPage?: boolean + /** 尽量不与后续内容分离。 */ + keepTogether?: boolean + /** 是否允许跨页拆分。 */ + allowSplit?: boolean +} + +/** 报表区域。 */ +export interface ReportSection { + id: string + kind: ReportSectionKind + /** 绑定的数据集,仅 detail / group-* 有意义。 */ + datasetId?: string + children: ReportElement[] + options?: ReportSectionOptions + /** 区域高度(mm)。detail 区域可省略,由内容决定。 */ + height?: number + name?: string + /** + * 分组键表达式,仅 group-header / group-footer 使用。 + * + * 例如 "row.category"。相邻行该值相同即属于同一组。 + * 表达式在数据集行作用域下求值(可访问 row / index / rownumber)。 + */ + groupExpression?: string + /** 分组级别,从 1 开始。数值小的为外层组。用于多级分组排序。 */ + groupLevel?: number + /** + * 组变更时是否强制换页(即每个分组独占一页起始)。 + */ + pageBreakBefore?: boolean +} + +/** 判断区域种类是否为 V1 实现范围。 */ +export function isV1SectionKind(kind: ReportSectionKind): boolean { + return V1_SECTION_KINDS.includes(kind) +} + +/** 创建区域。 */ +export function createSection( + id: string, + kind: ReportSectionKind, + children: ReportElement[] = [], +): ReportSection { + const section: ReportSection = { id, kind, children } + if (kind === "page-header" || kind === "page-footer") { + section.options = { repeatOnEachPage: true, keepTogether: true } + } + return section +} diff --git a/code/one-designer/packages/report-core/src/model/style.ts b/code/one-designer/packages/report-core/src/model/style.ts new file mode 100644 index 00000000..32cbdf46 --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/style.ts @@ -0,0 +1,149 @@ +/** + * 样式模型(对应设计文档 §6.4)。 + * + * 样式通过 ID 复用,并允许有限的继承(extends)和元素级覆盖。 + * 继承链在解析阶段展开,运行时不保留链式查找,避免深层继承带来的 + * 性能与可预测性问题。 + */ +import type { + BorderStyle, + HorizontalAlign, + VerticalAlign, +} from "./common.js" + +/** 样式定义。 */ +export interface StyleDefinition { + id: string + /** 继承的父样式 ID。 */ + extends?: string + + fontFamily?: string + /** 字号,单位 pt(排版惯例),Layout 阶段换算为目标单位。 */ + fontSize?: number + fontWeight?: "normal" | "bold" | number + fontStyle?: "normal" | "italic" + /** 行高倍数,1 表示与字号等高的紧凑行距。 */ + lineHeight?: number + color?: string + background?: string + + border?: BorderStyle + padding?: Partial> + + align?: HorizontalAlign + verticalAlign?: VerticalAlign + + /** 文本装饰。 */ + textDecoration?: "none" | "underline" | "line-through" + + /** 元素级覆盖允许出现任意额外键,由 Layout/Renderer 按需解释。 */ + [key: string]: unknown +} + +/** 已解析的样式:所有继承链已展开,可直接消费。 */ +export interface ResolvedStyle { + fontFamily: string + fontSize: number + fontWeight: "normal" | "bold" | number + fontStyle: "normal" | "italic" + lineHeight: number + color: string + background: string + border?: BorderStyle + padding: { top: number; right: number; bottom: number; left: number } + align: HorizontalAlign + verticalAlign: VerticalAlign + textDecoration: "none" | "underline" | "line-through" +} + +/** 默认已解析样式。 */ +export const DEFAULT_RESOLVED_STYLE: ResolvedStyle = Object.freeze({ + fontFamily: "sans-serif", + fontSize: 10, + fontWeight: "normal" as const, + fontStyle: "normal" as const, + lineHeight: 1.2, + color: "#000000", + background: "transparent", + padding: Object.freeze({ top: 0, right: 0, bottom: 0, left: 0 }), + align: "left" as const, + verticalAlign: "top" as const, + textDecoration: "none" as const, +}) + +/** + * 解析单条样式定义,可选地叠加元素级覆盖。 + * + * 只处理当前层,不跟随 extends;继承链的展开由 resolveStyleChain 完成。 + */ +export function mergeStyleOverride( + base: ResolvedStyle, + definition: StyleDefinition | undefined, +): ResolvedStyle { + if (!definition) return base + const out: ResolvedStyle = { ...base } + if (definition.fontFamily !== undefined) out.fontFamily = definition.fontFamily + if (definition.fontSize !== undefined) out.fontSize = definition.fontSize + if (definition.fontWeight !== undefined) out.fontWeight = definition.fontWeight + if (definition.fontStyle !== undefined) out.fontStyle = definition.fontStyle + if (definition.lineHeight !== undefined) out.lineHeight = definition.lineHeight + if (definition.color !== undefined) out.color = definition.color + if (definition.background !== undefined) out.background = definition.background + if (definition.border !== undefined) out.border = definition.border + if (definition.align !== undefined) out.align = definition.align + if (definition.verticalAlign !== undefined) out.verticalAlign = definition.verticalAlign + if (definition.textDecoration !== undefined) out.textDecoration = definition.textDecoration + if (definition.padding !== undefined) { + out.padding = { + top: definition.padding.top ?? base.padding.top, + right: definition.padding.right ?? base.padding.right, + bottom: definition.padding.bottom ?? base.padding.bottom, + left: definition.padding.left ?? base.padding.left, + } + } + return out +} + +/** 继承链解析结果。 */ +export interface StyleResolution { + style: ResolvedStyle + /** 检测到的循环继承链,非空表示解析失败并已回退到默认样式。 */ + cycle?: string[] +} + +/** + * 展开样式继承链。 + * + * 遇到循环继承时不抛异常,而是返回 cycle 信息并回退到默认样式, + * 交由调用方转成诊断信息(§13)。 + */ +export function resolveStyleChain( + styleId: string | undefined, + styles: Readonly>, +): StyleResolution { + if (!styleId) return { style: DEFAULT_RESOLVED_STYLE } + + const chain: StyleDefinition[] = [] + const seen = new Set() + const order: string[] = [] + + let currentId: string | undefined = styleId + while (currentId) { + if (seen.has(currentId)) { + return { style: DEFAULT_RESOLVED_STYLE, cycle: [...order, currentId] } + } + const definition: StyleDefinition | undefined = styles[currentId] + if (!definition) break + seen.add(currentId) + order.push(currentId) + chain.push(definition) + currentId = definition.extends + } + + // 从最基类开始应用,保证子类覆盖父类 + let resolved: ResolvedStyle = DEFAULT_RESOLVED_STYLE + for (let i = chain.length - 1; i >= 0; i -= 1) { + resolved = mergeStyleOverride(resolved, chain[i]) + } + return { style: resolved } +} diff --git a/code/one-designer/packages/report-core/src/model/table.ts b/code/one-designer/packages/report-core/src/model/table.ts new file mode 100644 index 00000000..16672e7b --- /dev/null +++ b/code/one-designer/packages/report-core/src/model/table.ts @@ -0,0 +1,81 @@ +/** + * 明细表模型(对应设计文档 §6.3)。 + * + * 明细表是 V1 的特殊元素,负责把数据集展开成多行。 + * 单元格内的元素坐标相对于当前单元格。 + */ +import type { Rect } from "./common.js" +import type { ReportElement, ValueBinding } from "./element.js" + +/** 表格列。 */ +export interface TableColumn { + id: string + /** 列宽(mm)。 */ + width: number + /** 表头文本。 */ + header?: string + /** 表头绑定,存在时优先于 header 字面量。 */ + headerBinding?: ValueBinding + /** 单元格整列绑定。 */ + binding?: ValueBinding + /** 单元格内自由布局的元素,坐标相对于单元格左上角。 */ + children?: ReportElement[] + /** 列级样式。 */ + styleId?: string + align?: "left" | "center" | "right" +} + +/** 行高策略。 */ +export type RowHeightMode = "auto" | "fixed" + +/** 明细表元素。 */ +export interface TableElement extends ReportElement { + type: "table" + /** 展开所用的数据集。 */ + datasetId: string + columns: TableColumn[] + /** 每页重复表头。 */ + repeatHeaderOnEachPage?: boolean + /** 表头行高度(mm)。 */ + headerHeight?: number + /** 数据行高度(mm)。 */ + rowHeight?: number + /** 行高模式。auto 表示按内容动态撑高。 */ + rowHeightMode?: RowHeightMode + /** 表格级边框与网格线。 */ + showGrid?: boolean + /** 无数据时的占位文本。 */ + emptyText?: string +} + +/** 判断元素是否为明细表。 */ +export function isTableElement(element: ReportElement): element is TableElement { + return element.type === "table" && Array.isArray((element as TableElement).columns) +} + +/** 表格总宽度(mm)。 */ +export function measureTableWidth(table: TableElement): number { + return table.columns.reduce((sum, column) => sum + (column.width ?? 0), 0) +} + +/** 创建明细表元素。 */ +export function createTableElement( + id: string, + frame: Rect, + datasetId: string, + columns: TableColumn[] = [], +): TableElement { + return { + id, + type: "table", + frame, + datasetId, + columns, + repeatHeaderOnEachPage: true, + headerHeight: 8, + rowHeight: 7, + rowHeightMode: "fixed", + showGrid: true, + emptyText: "无数据", + } +} diff --git a/code/one-designer/packages/report-core/src/plugin/context.ts b/code/one-designer/packages/report-core/src/plugin/context.ts new file mode 100644 index 00000000..b1bed0bc --- /dev/null +++ b/code/one-designer/packages/report-core/src/plugin/context.ts @@ -0,0 +1,6 @@ +/** + * 插件上下文(对应设计文档 §11)。 + * + * 只暴露注册所需的最小信息,不暴露文档内部结构。 + */ +export type { PluginContext, PluginKind, ReportPlugin } from "./plugin.js" diff --git a/code/one-designer/packages/report-core/src/plugin/datasource-plugin.ts b/code/one-designer/packages/report-core/src/plugin/datasource-plugin.ts new file mode 100644 index 00000000..fa2f866f --- /dev/null +++ b/code/one-designer/packages/report-core/src/plugin/datasource-plugin.ts @@ -0,0 +1,4 @@ +/** + * 数据源插件契约(对应设计文档 §11)。 + */ +export type { DataSourcePlugin } from "./plugin.js" diff --git a/code/one-designer/packages/report-core/src/plugin/element-plugin.ts b/code/one-designer/packages/report-core/src/plugin/element-plugin.ts new file mode 100644 index 00000000..caa97edc --- /dev/null +++ b/code/one-designer/packages/report-core/src/plugin/element-plugin.ts @@ -0,0 +1,6 @@ +/** + * 元素插件契约(对应设计文档 §11)。 + * + * 单独成文件,方便插件作者只依赖这一个入口而不引入整个 core。 + */ +export type { ElementPlugin } from "./plugin.js" diff --git a/code/one-designer/packages/report-core/src/plugin/exporter-plugin.ts b/code/one-designer/packages/report-core/src/plugin/exporter-plugin.ts new file mode 100644 index 00000000..cf3d2fc6 --- /dev/null +++ b/code/one-designer/packages/report-core/src/plugin/exporter-plugin.ts @@ -0,0 +1,6 @@ +/** + * 导出器插件契约(对应设计文档 §11)。 + * + * 用于 HTML / SVG / PDF 之外的自定义输出目标。 + */ +export type { ExporterPlugin } from "./plugin.js" diff --git a/code/one-designer/packages/report-core/src/plugin/plugin.ts b/code/one-designer/packages/report-core/src/plugin/plugin.ts new file mode 100644 index 00000000..290188d1 --- /dev/null +++ b/code/one-designer/packages/report-core/src/plugin/plugin.ts @@ -0,0 +1,147 @@ +/** + * 插件契约(对应设计文档 §11)。 + * + * 核心约束:插件只通过契约接入,不允许 Core 直接导入插件实现。 + * 插件引用只保存 id、version 和配置;插件缺失时应产生诊断信息, + * 而不是阻止整个文档被读取。 + */ +import type { ReportElement } from "../model/element.js" +import type { DataSourceDefinition } from "../model/datasource.js" + +/** 插件类型标识。 */ +export type PluginKind = "element" | "datasource" | "exporter" + +/** + * 元素插件。 + * + * schema 使用「未知形状」是有意为之:Core 不绑定任何具体校验库, + * 宿主可以传 JSON Schema、Zod、Valibot 或自定义描述,Core 只做透传 + * 和存在性检查,具体校验由插件适配器负责。 + */ +export interface ElementPlugin { + /** 元素类型标识,对应 ReportElement.type。 */ + type: string + /** 插件展示名。 */ + label?: string + /** 是否允许包含子元素,用于校验和 Designer 交互。 */ + isContainer?: boolean + /** props 的结构描述,由插件自行解释。 */ + schema?: unknown + /** 创建一个带默认 props 的实例。 */ + createDefault(options?: { id?: string; frame?: ReportElement["frame"] }): ReportElement + /** 可选:校验 props,返回错误消息数组(空数组表示通过)。 */ + validate?(element: ReportElement): string[] + /** 可选:为外部资源声明允许的来源,供 Renderer 做安全校验。 */ + externalResources?: "none" | "image" | "any" +} + +/** 数据源插件。 */ +export interface DataSourcePlugin { + /** 数据源类型标识,对应 DataSourceDefinition.type。 */ + type: string + label?: string + /** 默认配置,用于 Designer 新建数据源。 */ + createDefaultConfig?(): Record +} + +/** 导出器插件。用于 HTML / SVG / PDF 之外的额外输出目标。 */ +export interface ExporterPlugin { + /** 导出目标标识,如 "pdf"、"png"。 */ + format: string + label?: string +} + +/** 插件包。 */ +export interface ReportPlugin { + id: string + version: string + /** 插件声明兼容的 core schema 版本范围描述,仅作提示与诊断。 */ + compatibleSchemaVersion?: string + elements?: ElementPlugin[] + datasources?: DataSourcePlugin[] + exporters?: ExporterPlugin[] + /** 插件初始化钩子,用于注册适配器等。 */ + setup?(context: PluginContext): void +} + +/** + * 插件上下文。 + * + * 只暴露注册能力,不暴露文档内部结构,避免插件反向操纵 Core 状态。 + */ +export interface PluginContext { + pluginId: string + pluginVersion: string + /** 当前 core 支持的 schema 版本。 */ + schemaVersion: number +} + +/** + * 版本兼容性判断(语义化版本的宽松实现)。 + * + * requirement 支持 ">=1.0.0"、"^1.2.0"、"1.x"、"*"。 + * 无法解析时返回 true 并交由诊断提示,避免因解析失败阻断插件加载。 + */ +export function isVersionCompatible(version: string, requirement?: string): boolean { + if (!requirement || requirement === "*") return true + const actual = parseVersion(version) + if (!actual) return true + + const match = requirement.trim().match(/^(>=|\^|~)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?$/) + if (!match) return true + + const operator = match[1] ?? "" + const major = Number(match[2]) + const minorRaw = match[3] + const minor = minorRaw === undefined || minorRaw === "x" || minorRaw === "*" + ? undefined + : Number(minorRaw) + + switch (operator) { + case ">=": + if (actual.major !== major) return actual.major > major + if (minor === undefined) return true + return actual.minor >= minor + case "^": + return actual.major === major + case "~": + return actual.major === major && (minor === undefined || actual.minor === minor) + default: + // 无操作符:按 major 匹配,minor 缺省视为任意 + if (actual.major !== major) return false + return minor === undefined || actual.minor === minor + } +} + +interface ParsedVersion { + major: number + minor: number + patch: number +} + +function parseVersion(version: string): ParsedVersion | null { + const match = version.trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/) + if (!match) return null + return { + major: Number(match[1]), + minor: Number(match[2] ?? 0), + patch: Number(match[3] ?? 0), + } +} + +/** 从数据源定义创建插件所需的最小信息(用于诊断)。 */ +export interface MissingPluginInfo { + pluginId: string + version: string + kind: PluginKind + /** 用到该插件的元素 ID 或数据源 ID。 */ + usedBy?: string +} + +/** 依据数据源定义判断所需的插件。 */ +export function describeDataSourcePlugin( + source: DataSourceDefinition, +): MissingPluginInfo | null { + if (!source.type) return null + return { pluginId: source.type, version: "*", kind: "datasource", usedBy: source.id } +} diff --git a/code/one-designer/packages/report-core/src/plugin/registry.ts b/code/one-designer/packages/report-core/src/plugin/registry.ts new file mode 100644 index 00000000..9f2c1c2f --- /dev/null +++ b/code/one-designer/packages/report-core/src/plugin/registry.ts @@ -0,0 +1,257 @@ +/** + * 插件注册表(对应设计文档 §11 与 §4.1)。 + * + * 职责: + * - 保存已注册插件,提供按类型查找元素插件的能力。 + * - 对缺插件与版本不兼容产出诊断(§13),而不是抛异常。 + * - 不导入任何具体插件实现,只接受契约对象。 + */ +import type { Diagnostic } from "../model/common.js" +import { DIAGNOSTIC_CODES, type DiagnosticBag } from "../diagnostics/diagnostic.js" +import { CURRENT_SCHEMA_VERSION } from "../schema/version.js" +import { + isVersionCompatible, + type DataSourcePlugin, + type ElementPlugin, + type ExporterPlugin, + type MissingPluginInfo, + type PluginContext, + type ReportPlugin, +} from "./plugin.js" + +/** 注册结果。 */ +export interface RegisterResult { + ok: boolean + diagnostics: Diagnostic[] +} + +/** + * 插件注册表。 + * + * 一个注册表实例对应一个运行时(Editor 或 Renderer)。Core 本身不持有 + * 全局单例,避免在 SSR / 多文档场景下互相污染。 + */ +export class PluginRegistry { + private readonly plugins = new Map() + private readonly elementPlugins = new Map() + private readonly dataSourcePlugins = new Map() + private readonly exporterPlugins = new Map() + + /** 注册一个插件。重复注册同 id 时返回失败诊断(不覆盖)。 */ + register(plugin: ReportPlugin): RegisterResult { + const diagnostics: Diagnostic[] = [] + + if (!plugin.id) { + diagnostics.push({ + severity: "error", + code: DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING, + message: "插件缺少 id", + }) + return { ok: false, diagnostics } + } + + if (this.plugins.has(plugin.id)) { + diagnostics.push({ + severity: "error", + code: DIAGNOSTIC_CODES.PLUGIN_ALREADY_REGISTERED, + message: `插件 "${plugin.id}" 已注册,忽略重复注册`, + }) + return { ok: false, diagnostics } + } + + // 版本兼容性:不匹配时产生警告但仍然注册,避免阻断打开文档 + if ( + plugin.compatibleSchemaVersion && + !isVersionCompatible(String(CURRENT_SCHEMA_VERSION), plugin.compatibleSchemaVersion) + ) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.PLUGIN_VERSION_INCOMPATIBLE, + message: + `插件 "${plugin.id}@${plugin.version}" 声明兼容 ${plugin.compatibleSchemaVersion},` + + `当前 schema 为 ${CURRENT_SCHEMA_VERSION}`, + }) + } + + // 元素类型冲突检查 + for (const element of plugin.elements ?? []) { + const existing = this.elementPlugins.get(element.type) + if (existing) { + diagnostics.push({ + severity: "error", + code: DIAGNOSTIC_CODES.PLUGIN_ALREADY_REGISTERED, + message: `元素类型 "${element.type}" 已被其他插件注册`, + }) + return { ok: false, diagnostics } + } + } + + this.plugins.set(plugin.id, plugin) + for (const element of plugin.elements ?? []) { + this.elementPlugins.set(element.type, element) + } + for (const datasource of plugin.datasources ?? []) { + this.dataSourcePlugins.set(datasource.type, datasource) + } + for (const exporter of plugin.exporters ?? []) { + this.exporterPlugins.set(exporter.format, exporter) + } + + if (plugin.setup) { + const context: PluginContext = { + pluginId: plugin.id, + pluginVersion: plugin.version, + schemaVersion: CURRENT_SCHEMA_VERSION, + } + plugin.setup(context) + } + + return { ok: true, diagnostics } + } + + /** 批量注册。 */ + registerAll(plugins: readonly ReportPlugin[]): RegisterResult { + const diagnostics: Diagnostic[] = [] + let ok = true + for (const plugin of plugins) { + const result = this.register(plugin) + diagnostics.push(...result.diagnostics) + if (!result.ok) ok = false + } + return { ok, diagnostics } + } + + /** 注销插件。 */ + unregister(pluginId: string): boolean { + const plugin = this.plugins.get(pluginId) + if (!plugin) return false + for (const element of plugin.elements ?? []) this.elementPlugins.delete(element.type) + for (const datasource of plugin.datasources ?? []) this.dataSourcePlugins.delete(datasource.type) + for (const exporter of plugin.exporters ?? []) this.exporterPlugins.delete(exporter.format) + this.plugins.delete(pluginId) + return true + } + + has(pluginId: string): boolean { + return this.plugins.has(pluginId) + } + + get(pluginId: string): ReportPlugin | undefined { + return this.plugins.get(pluginId) + } + + /** 按元素类型取插件。 */ + getElementPlugin(type: string): ElementPlugin | undefined { + return this.elementPlugins.get(type) + } + + getDataSourcePlugin(type: string): DataSourcePlugin | undefined { + return this.dataSourcePlugins.get(type) + } + + getExporterPlugin(format: string): ExporterPlugin | undefined { + return this.exporterPlugins.get(format) + } + + /** 已注册的全部元素类型,供校验判断元素是否合法。 */ + elementTypes(): Set { + return new Set(this.elementPlugins.keys()) + } + + /** 允许包含子元素的插件类型。 */ + containerTypes(): Set { + const out = new Set() + for (const [type, plugin] of this.elementPlugins) { + if (plugin.isContainer) out.add(type) + } + return out + } + + list(): ReportPlugin[] { + return [...this.plugins.values()] + } + + clear(): void { + this.plugins.clear() + this.elementPlugins.clear() + this.dataSourcePlugins.clear() + this.exporterPlugins.clear() + } + + /** + * 检查文档声明的插件引用是否都已注册(§11)。 + * + * 缺失或版本不匹配只产生诊断,不阻止文档读取。 + */ + checkReferences( + references: readonly { id: string; version: string }[] | undefined, + ): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + for (const reference of references ?? []) { + const plugin = this.plugins.get(reference.id) + if (!plugin) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.PLUGIN_MISSING, + message: `文档引用的插件 "${reference.id}" 未注册,相关元素将无法正确布局或渲染`, + }) + continue + } + if (reference.version && !isVersionCompatible(plugin.version, reference.version)) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.PLUGIN_VERSION_INCOMPATIBLE, + message: + `文档要求插件 "${reference.id}" 版本 ${reference.version},` + + `当前注册的是 ${plugin.version}`, + }) + } + } + return diagnostics + } + + /** 收集文档中实际用到但未注册的插件,便于给出可定位诊断。 */ + collectMissing(infos: readonly MissingPluginInfo[]): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + for (const info of infos) { + const registered = + info.kind === "element" + ? this.elementPlugins.has(info.pluginId) + : info.kind === "datasource" + ? this.dataSourcePlugins.has(info.pluginId) + : this.exporterPlugins.has(info.pluginId) + if (!registered) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.PLUGIN_MISSING, + message: `缺少提供 "${info.pluginId}" 的${describeKind(info.kind)}插件`, + ...(info.usedBy ? { elementId: info.usedBy } :{}), + }) + } + } + return diagnostics + } +} + +function describeKind(kind: MissingPluginInfo["kind"]): string { + switch (kind) { + case "element": + return "元素" + case "datasource": + return "数据源" + case "exporter": + return "导出器" + } +} + +/** 把注册结果里的诊断写入收集器。 */ +export function reportRegisterResult(bag: DiagnosticBag, result: RegisterResult): void { + for (const diagnostic of result.diagnostics) bag.add(diagnostic) +} + +/** 创建一个空的注册表。 */ +export function createPluginRegistry(plugins: readonly ReportPlugin[] = []): PluginRegistry { + const registry = new PluginRegistry() + registry.registerAll(plugins) + return registry +} diff --git a/code/one-designer/packages/report-core/src/schema/defaults.ts b/code/one-designer/packages/report-core/src/schema/defaults.ts new file mode 100644 index 00000000..d186a4ee --- /dev/null +++ b/code/one-designer/packages/report-core/src/schema/defaults.ts @@ -0,0 +1,195 @@ +/** + * 默认值(对应设计文档 §12 的 normalize 阶段和 §16 阶段 1)。 + * + * 默认值的唯一来源。create / deserialize 都通过这里补齐缺省字段, + * 保证「新建文档」和「读取旧文档」得到一致的结构。 + */ +import { createDefaultPageSettings, type PageSettings } from "../model/page.js" +import type { ReportDocument, ReportMetadata } from "../model/report.js" +import type { ReportSection, ReportSectionKind } from "../model/section.js" +import type { ReportElement } from "../model/element.js" +import type { TableColumn, TableElement } from "../model/table.js" +import type { Rect } from "../model/common.js" +import { createElementId, createSectionId } from "../utils/id.js" + +/** 新元素的默认尺寸(mm)。 */ +export const DEFAULT_ELEMENT_SIZE: Readonly> = + Object.freeze({ + text: { width: 40, height: 8 }, + image: { width: 40, height: 30 }, + line: { width: 40, height: 0.2 }, + rect: { width: 40, height: 20 }, + container: { width: 60, height: 40 }, + table: { width: 180, height: 40 }, + }) + +/** 未知类型元素的兜底尺寸。 */ +export const FALLBACK_ELEMENT_SIZE = Object.freeze({ width: 30, height: 10 }) + +/** 取某类型元素的默认尺寸。 */ +export function getDefaultElementSize(type: string): { width: number; height: number } { + return DEFAULT_ELEMENT_SIZE[type] ?? FALLBACK_ELEMENT_SIZE +} + +/** 默认矩形。 */ +export function createDefaultFrame(type: string, x = 0, y = 0): Rect { + const size = getDefaultElementSize(type) + return { x, y, width: size.width, height: size.height } +} + +/** 默认页面设置。 */ +export { createDefaultPageSettings } + +/** 默认区域选项。 */ +export function createDefaultSectionOptions( + kind: ReportSectionKind, +): ReportSection["options"] { + if (kind === "page-header" || kind === "page-footer") { + return { repeatOnEachPage: true, keepTogether: true } + } + if (kind === "detail") { + return { allowSplit: true } + } + return undefined +} + +/** 默认表格列。 */ +export function createDefaultTableColumn(width = 30): TableColumn { + return { id: `col_${Math.random().toString(36).slice(2, 10)}`, width } +} + +/** 默认明细表。 */ +export function createDefaultTable( + id: string, + datasetId: string, + frame?: Rect, +): TableElement { + return { + id, + type: "table", + frame: frame ?? createDefaultFrame("table"), + datasetId, + columns: [], + repeatHeaderOnEachPage: true, + headerHeight: 8, + rowHeight: 7, + rowHeightMode: "fixed", + showGrid: true, + emptyText: "无数据", + } +} + +/** 默认元信息。 */ +export function createDefaultMetadata(name: string): ReportMetadata { + return { + title: name, + createdAt: new Date().toISOString(), + } +} + +/** + * 元素的缺省字段表。 + * + * 用于 normalize 阶段给缺字段补默认值。注意这里只给「结构性」字段 + * 补默认,不给 props 里的业务字段补,避免把插件的语义写死在 core。 + */ +export function normalizeElement(element: ReportElement): ReportElement { + const out: ReportElement = { + id: element.id ?? createElementId(), + type: element.type ?? "text", + frame: normalizeRect(element.frame, element.type ?? "text"), + } + if (element.styleId !== undefined) out.styleId = element.styleId + if (element.binding !== undefined) out.binding = element.binding + if (element.props !== undefined) out.props = element.props + if (element.style !== undefined) out.style = element.style + if (element.name !== undefined) out.name = element.name + if (element.locked !== undefined) out.locked = element.locked + if (element.hidden !== undefined) out.hidden = element.hidden + if (element.children !== undefined) { + out.children = element.children.map(normalizeElement) + } + if (element.type === "table") { + const table = element as TableElement + const columns = Array.isArray(table.columns) ? table.columns : [] + ;(out as TableElement).datasetId = table.datasetId + ;(out as TableElement).columns = columns.map(normalizeColumn) + if (table.repeatHeaderOnEachPage !== undefined) { + ;(out as TableElement).repeatHeaderOnEachPage = table.repeatHeaderOnEachPage + } + if (table.headerHeight !== undefined) (out as TableElement).headerHeight = table.headerHeight + if (table.rowHeight !== undefined) (out as TableElement).rowHeight = table.rowHeight + if (table.rowHeightMode !== undefined) (out as TableElement).rowHeightMode = table.rowHeightMode + if (table.showGrid !== undefined) (out as TableElement).showGrid = table.showGrid + if (table.emptyText !== undefined) (out as TableElement).emptyText = table.emptyText + } + return out +} + +/** 规格化矩形:缺失字段补 0,负宽高取绝对值。 */ +export function normalizeRect(rect: Rect | undefined, type: string): Rect { + const fallback = createDefaultFrame(type) + if (!rect) return fallback + return { + x: Number.isFinite(rect.x) ? rect.x : 0, + y: Number.isFinite(rect.y) ? rect.y : 0, + width: Number.isFinite(rect.width) ? Math.abs(rect.width) : fallback.width, + height: Number.isFinite(rect.height) ? Math.abs(rect.height) : fallback.height, + } +} + +/** 规格化表格列。 */ +export function normalizeColumn(column: TableColumn): TableColumn { + const out: TableColumn = { + id: column.id ?? createDefaultTableColumn().id, + width: Number.isFinite(column.width) ? Math.abs(column.width) : 30, + } + if (column.header !== undefined) out.header = column.header + if (column.headerBinding !== undefined) out.headerBinding = column.headerBinding + if (column.binding !== undefined) out.binding = column.binding + if (column.styleId !== undefined) out.styleId = column.styleId + if (column.align !== undefined) out.align = column.align + if (column.children !== undefined) out.children = column.children.map(normalizeElement) + return out +} + +/** 规格化区域。 */ +export function normalizeSection(section: ReportSection): ReportSection { + const out: ReportSection = { + id: section.id ?? createSectionId(), + kind: section.kind ?? "detail", + children: (section.children ?? []).map(normalizeElement), + } + if (section.datasetId !== undefined) out.datasetId = section.datasetId + if (section.height !== undefined) out.height = section.height + if (section.name !== undefined) out.name = section.name + const options = section.options ?? createDefaultSectionOptions(out.kind) + if (options !== undefined) out.options = options + return out +} + +/** 新建空白文档所需的骨架(不含 id / name,由 create 决定)。 */ +export interface EmptyDocumentSkeleton { + page: PageSettings + styles: ReportDocument["styles"] + dataSources: ReportDocument["dataSources"] + datasets: ReportDocument["datasets"] + parameters: ReportDocument["parameters"] + sections: ReportSection[] +} + +/** 创建空白文档骨架:默认 A4 纵向 + 页眉 / 明细 / 页脚三个区域。 */ +export function createEmptyDocumentSkeleton(): EmptyDocumentSkeleton { + return { + page: createDefaultPageSettings(), + styles: {}, + dataSources: {}, + datasets: {}, + parameters: {}, + sections: [ + { id: createSectionId(), kind: "page-header", children: [], options: createDefaultSectionOptions("page-header") }, + { id: createSectionId(), kind: "detail", children: [], options: createDefaultSectionOptions("detail") }, + { id: createSectionId(), kind: "page-footer", children: [], options: createDefaultSectionOptions("page-footer") }, + ] as ReportSection[], + } +} diff --git a/code/one-designer/packages/report-core/src/schema/validation.ts b/code/one-designer/packages/report-core/src/schema/validation.ts new file mode 100644 index 00000000..10b4eef6 --- /dev/null +++ b/code/one-designer/packages/report-core/src/schema/validation.ts @@ -0,0 +1,571 @@ +/** + * 结构校验(对应设计文档 §12)。 + * + * 要求: + * - 校验错误必须指出字段路径和元素 ID。 + * - 校验不抛异常做业务控制,统一返回 Diagnostic[](§13)。 + * + * 校验分两级: + * - validateStructure:只检查 JSON 形状是否满足当前 schema,用于反序列化入口。 + * - validateDocument:在结构成立的基础上检查引用完整性、类型合法性和业务约束。 + */ +import type { Diagnostic } from "../model/common.js" +import { DIAGNOSTIC_CODES, error, toBag, warning, type DiagnosticBag } from "../diagnostics/diagnostic.js" +import { + isPlainData, + isPlainObject, +} from "../utils/deep.js" +import { isValidId } from "../utils/id.js" +import { + BUILTIN_ELEMENT_TYPES, + canHaveChildren, +} from "../model/element.js" +import { isV1SectionKind, RESERVED_SECTION_KINDS, type ReportSectionKind } from "../model/section.js" +import { isValidParameterName } from "../model/parameter.js" +import { isValidSchemaVersion, MIN_SCHEMA_VERSION } from "./version.js" + +/** 允许的区域种类全集。 */ +const ALL_SECTION_KINDS: readonly ReportSectionKind[] = [ + "report-header", + "page-header", + "detail", + "group-header", + "group-footer", + "summary", + "page-footer", +] + +const PAPER_KINDS = ["A4", "A5", "custom"] as const +const ORIENTATIONS = ["portrait", "landscape"] as const +const ALIGNMENTS = ["left", "center", "right"] as const + +/** 校验 JSON Pointer 路径拼接。 */ +function join(base: string, key: string | number): string { + return `${base}/${key}` +} + +/** + * 第一级:结构校验。 + * + * 只回答「这份 JSON 是否可能是 ReportDocument」,不检查引用完整性, + * 因为迁移阶段需要先通过这一关,之后才可能补齐缺失引用。 + */ +export function validateStructure(input: unknown): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + const bag = { + error(code: string, message: string, extra?: { path?: string; elementId?: string }) { + diagnostics.push(error(code, message, extra)) + }, + warn(code: string, message: string, extra?: { path?: string; elementId?: string }) { + diagnostics.push(warning(code, message, extra)) + }, + } + + if (!isPlainObject(input)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_INVALID, "文档根节点必须是普通对象", { path: "/" }) + return diagnostics + } + + // schemaVersion + const rawVersion = (input as Record).schemaVersion + if (rawVersion === undefined) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_VERSION_MISSING, "缺少 schemaVersion 字段", { + path: "/schemaVersion", + }) + } else if (!isValidSchemaVersion(rawVersion)) { + bag.error( + DIAGNOSTIC_CODES.SCHEMA_VERSION_UNKNOWN, + `schemaVersion 必须是 >= ${MIN_SCHEMA_VERSION} 的整数,收到 ${JSON.stringify(rawVersion)}`, + { path: "/schemaVersion" }, + ) + } + + // 必填字符串 + for (const key of ["id", "name"] as const) { + const value = (input as Record)[key] + if (typeof value !== "string" || value.length === 0) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING, `缺少必填字段 ${key}`, { + path: join("", key), + }) + } + } + + // page + const page = (input as Record).page + if (page === undefined) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING, "缺少必填字段 page", { path: "/page" }) + } else if (!isPlainObject(page)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "page 必须是对象", { path: "/page" }) + } else { + const paper = (page as Record).paper + if (paper !== undefined && !PAPER_KINDS.includes(paper as (typeof PAPER_KINDS)[number])) { + bag.error( + DIAGNOSTIC_CODES.SCHEMA_ENUM_INVALID, + `page.paper 必须是 ${PAPER_KINDS.join(" / ")} 之一,收到 ${JSON.stringify(paper)}`, + { path: "/page/paper" }, + ) + } + const orientation = (page as Record).orientation + if ( + orientation !== undefined && + !ORIENTATIONS.includes(orientation as (typeof ORIENTATIONS)[number]) + ) { + bag.error( + DIAGNOSTIC_CODES.SCHEMA_ENUM_INVALID, + `page.orientation 必须是 portrait / landscape 之一`, + { path: "/page/orientation" }, + ) + } + const customSize = (page as Record).customSize + if (customSize !== undefined) { + if (!isPlainObject(customSize)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "page.customSize 必须是对象", { + path: "/page/customSize", + }) + } else { + for (const key of ["width", "height"] as const) { + const value = (customSize as Record)[key] + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + bag.error( + DIAGNOSTIC_CODES.SCHEMA_RANGE_INVALID, + `page.customSize.${key} 必须是正数`, + { path: `/page/customSize/${key}` }, + ) + } + } + } + } + } + + // sections + const sections = (input as Record).sections + if (sections === undefined) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING, "缺少必填字段 sections", { + path: "/sections", + }) + } else if (!Array.isArray(sections)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "sections 必须是数组", { path: "/sections" }) + } else { + sections.forEach((section, index) => { + const path = `/sections/${index}` + if (!isPlainObject(section)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "区域必须是对象", { path }) + return + } + const kind = (section as Record).kind + if (!ALL_SECTION_KINDS.includes(kind as ReportSectionKind)) { + bag.error( + DIAGNOSTIC_CODES.SCHEMA_ENUM_INVALID, + `未知的区域种类 ${JSON.stringify(kind)}`, + { path: `${path}/kind` }, + ) + } + const children = (section as Record).children + if (children !== undefined && !Array.isArray(children)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "区域 children 必须是数组", { + path: `${path}/children`, + }) + } + if (Array.isArray(children)) { + validateElementStructure(children, `${path}/children`, bag) + } + }) + } + + // 集合型字段 + for (const key of ["styles", "dataSources", "datasets", "parameters"] as const) { + const value = (input as Record)[key] + if (value !== undefined && !isPlainObject(value)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, `${key} 必须是对象(字典)`, { + path: `/${key}`, + }) + } + } + + // 纯数据检查(§3.5:文档 JSON 中不能保存函数、DOM 节点或运行时对象) + if (!isPlainData(input)) { + bag.error( + DIAGNOSTIC_CODES.SCHEMA_INVALID, + "文档包含不可序列化的值(函数、DOM 节点或运行时对象)", + { path: "/" }, + ) + } + + return diagnostics +} + +/** 递归校验元素结构。 */ +function validateElementStructure( + elements: unknown[], + basePath: string, + bag: { error: (code: string, message: string, extra?: { path?: string; elementId?: string }) => void }, +): void { + elements.forEach((element, index) => { + const path = `${basePath}/${index}` + if (!isPlainObject(element)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "元素必须是对象", { path }) + return + } + const record = element as Record + const id = typeof record.id === "string" ? record.id : undefined + if (!isValidId(record.id)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING, "元素缺少合法的 id", { path: `${path}/id` }) + } + if (typeof record.type !== "string" || record.type.length === 0) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING, "元素缺少 type", { path: `${path}/type` }) + } + const frame = record.frame + if (frame !== undefined && !isPlainObject(frame)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "元素 frame 必须是对象", { + path: `${path}/frame`, + ...(id ? { elementId: id } : {}), + }) + } + const children = record.children + if (children !== undefined) { + if (!Array.isArray(children)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "元素 children 必须是数组", { + path: `${path}/children`, + ...(id ? { elementId: id } : {}), + }) + } else { + validateElementStructure(children, `${path}/children`, bag) + } + } + // 表格列 + if (record.type === "table") { + const columns = record.columns + if (columns !== undefined && !Array.isArray(columns)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "表格 columns 必须是数组", { + path: `${path}/columns`, + }) + } else if (Array.isArray(columns)) { + columns.forEach((column, columnIndex) => { + const columnPath = `${path}/columns/${columnIndex}` + if (!isPlainObject(column)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "表格列必须是对象", { + path: columnPath, + }) + return + } + const columnChildren = (column as Record).children + if (columnChildren !== undefined) { + if (!Array.isArray(columnChildren)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_TYPE_MISMATCH, "表格列 children 必须是数组", { + path: `${columnPath}/children`, + }) + } else { + validateElementStructure(columnChildren, `${columnPath}/children`, bag) + } + } + }) + } + } + }) +} + +/** + * 第二级:完整文档校验。 + * + * 在结构成立后检查引用完整性、业务约束,并产出可定位的诊断。 + */ +export function validateDocument( + document: unknown, + options: { pluginElementTypes?: ReadonlySet } = {}, +): Diagnostic[] { + const diagnostics: Diagnostic[] = [...validateStructure(document)] + if (diagnostics.some((item) => item.severity === "error")) { + // 结构已不成立,继续做引用检查只会产生噪音 + return diagnostics + } + const bag: DiagnosticBag = toBag(diagnostics) + const doc = document as Record + + const dataSourceIds = new Set(Object.keys((doc.dataSources as object) ?? {})) + const datasetIds = new Set(Object.keys((doc.datasets as object) ?? {})) + const styleIds = new Set(Object.keys((doc.styles as object) ?? {})) + + // 数据集引用的数据源 + for (const [datasetId, dataset] of Object.entries( + (doc.datasets as Record) ?? {}, + )) { + if (!isPlainObject(dataset)) continue + const sourceId = (dataset as Record).sourceId + if (typeof sourceId === "string" && sourceId.length > 0 && !dataSourceIds.has(sourceId)) { + bag.warn( + DIAGNOSTIC_CODES.REF_DANGLING_DATASOURCE, + `数据集 "${datasetId}" 引用了不存在的数据源 "${sourceId}"`, + { path: `/datasets/${datasetId}/sourceId` }, + ) + } + } + + // 样式继承链 + const styleCycle = detectStyleCycles((doc.styles as Record) ?? {}) + for (const cycle of styleCycle) { + bag.error( + DIAGNOSTIC_CODES.REF_CYCLE_DETECTED, + `样式继承存在循环:${cycle.join(" → ")}`, + { path: `/styles/${cycle[0]}` }, + ) + } + for (const [styleId, style] of Object.entries((doc.styles as Record) ?? {})) { + if (!isPlainObject(style)) continue + const parent = (style as Record).extends + if (typeof parent === "string" && !styleIds.has(parent)) { + bag.warn( + DIAGNOSTIC_CODES.REF_DANGLING_STYLE, + `样式 "${styleId}" 继承了不存在的样式 "${parent}"`, + { path: `/styles/${styleId}/extends` }, + ) + } + } + + // 参数名 + const seenParameterNames = new Set() + for (const [parameterId, parameter] of Object.entries( + (doc.parameters as Record) ?? {}, + )) { + if (!isPlainObject(parameter)) continue + const name = (parameter as Record).name + if (typeof name === "string") { + if (!isValidParameterName(name)) { + bag.error( + DIAGNOSTIC_CODES.SCHEMA_INVALID, + `参数名 "${name}" 不是合法标识符,无法在表达式中引用`, + { path: `/parameters/${parameterId}/name` }, + ) + } + if (seenParameterNames.has(name)) { + bag.error(DIAGNOSTIC_CODES.REF_DUPLICATE_ID, `参数名 "${name}" 重复`, { + path: `/parameters/${parameterId}/name`, + }) + } + seenParameterNames.add(name) + } + } + + // 元素树 + const seenElementIds = new Map() + const pluginTypes = options.pluginElementTypes + const sections = (doc.sections as unknown[]) ?? [] + sections.forEach((section, index) => { + if (!isPlainObject(section)) return + const record = section as Record + const kind = record.kind as ReportSectionKind + const sectionPath = `/sections/${index}` + + if (RESERVED_SECTION_KINDS.includes(kind)) { + bag.warn( + DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE, + `区域 "${kind}" 在 V1 中仅保留模型扩展点,暂不参与渲染`, + { path: `${sectionPath}/kind` }, + ) + } + if (!isV1SectionKind(kind) && !RESERVED_SECTION_KINDS.includes(kind)) { + bag.error(DIAGNOSTIC_CODES.SCHEMA_ENUM_INVALID, `未知的区域种类 "${kind}"`, { + path: `${sectionPath}/kind`, + }) + } + + const datasetId = record.datasetId + if (typeof datasetId === "string" && datasetId.length > 0 && !datasetIds.has(datasetId)) { + bag.warn( + DIAGNOSTIC_CODES.REF_DANGLING_DATASET, + `区域引用了不存在的数据集 "${datasetId}"`, + { path: `${sectionPath}/datasetId` }, + ) + } + + if (Array.isArray(record.children)) { + validateElements( + record.children, + `${sectionPath}/children`, + { bag, datasetIds, styleIds, pluginTypes, seenElementIds }, + ) + } + }) + + // 必须返回 bag 的内容而不是初始的 diagnostics 数组: + // toBag 会复制一份内部列表,之后写入 bag 的诊断不会出现在 diagnostics 中。 + return bag.all() +} + +interface ValidateContext { + bag: DiagnosticBag + datasetIds: ReadonlySet + styleIds: ReadonlySet + pluginTypes?: ReadonlySet + seenElementIds: Map +} + +/** 递归校验元素:类型、引用、重复 ID。 */ +function validateElements( + elements: unknown[], + basePath: string, + ctx: ValidateContext, +): void { + elements.forEach((element, index) => { + const path = `${basePath}/${index}` + if (!isPlainObject(element)) return + const record = element as Record + const id = typeof record.id === "string" ? record.id : "" + const type = typeof record.type === "string" ? record.type : "" + const where = id ? { elementId: id } : {} + + // 重复 ID + if (id) { + const previous = ctx.seenElementIds.get(id) + if (previous) { + ctx.bag.error(DIAGNOSTIC_CODES.REF_DUPLICATE_ID, `元素 ID "${id}" 重复`, { path, ...where }) + } else { + ctx.seenElementIds.set(id, path) + } + } + + // 元素类型:内置 或 已注册插件 + const isBuiltin = (BUILTIN_ELEMENT_TYPES as readonly string[]).includes(type) + const isPlugin = ctx.pluginTypes?.has(type) ?? false + if (type && !isBuiltin && !isPlugin) { + if (ctx.pluginTypes) { + ctx.bag.error( + DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE, + `未知的元素类型 "${type}",且没有已注册的插件提供该类型`, + { path: `${path}/type`, ...where }, + ) + } else { + // 未提供插件清单时降级为警告,避免误报(插件可能由宿主稍后注册) + ctx.bag.warn( + DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE, + `元素类型 "${type}" 不是内置类型;如为插件元素,请注册对应插件`, + { path: `${path}/type`, ...where }, + ) + } + } + + // styleId 引用 + const styleId = record.styleId + if (typeof styleId === "string" && styleId.length > 0 && !ctx.styleIds.has(styleId)) { + ctx.bag.warn(DIAGNOSTIC_CODES.REF_DANGLING_STYLE, `元素引用了不存在的样式 "${styleId}"`, { + path: `${path}/styleId`, + ...where, + }) + } + + // frame 合法性 + if (isPlainObject(record.frame)) { + const frame = record.frame as Record + for (const key of ["x", "y", "width", "height"] as const) { + const value = frame[key] + if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) { + ctx.bag.error( + DIAGNOSTIC_CODES.ELEMENT_FRAME_INVALID, + `frame.${key} 必须是有限数字`, + { path: `${path}/frame/${key}`, ...where }, + ) + } + } + const width = frame.width + const height = frame.height + if (typeof width === "number" && width < 0) { + ctx.bag.error(DIAGNOSTIC_CODES.ELEMENT_FRAME_INVALID, "frame.width 不能为负", { + path: `${path}/frame/width`, + ...where, + }) + } + if (typeof height === "number" && height < 0) { + ctx.bag.error(DIAGNOSTIC_CODES.ELEMENT_FRAME_INVALID, "frame.height 不能为负", { + path: `${path}/frame/height`, + ...where, + }) + } + } + + // 叶子元素不允许 children + const children = record.children + if (Array.isArray(children) && children.length > 0) { + if (!canHaveChildren(type, undefined)) { + const isContainerCapable = isPlugin || canHaveChildren(type, undefined) + if (!isContainerCapable && isBuiltin) { + ctx.bag.error( + DIAGNOSTIC_CODES.ELEMENT_CHILD_NOT_ALLOWED, + `元素类型 "${type}" 不允许包含子元素`, + { path: `${path}/children`, ...where }, + ) + } + } + validateElements(children, `${path}/children`, ctx) + } + + // 表格专属检查 + if (type === "table") { + const datasetId = record.datasetId + if (typeof datasetId !== "string" || datasetId.length === 0) { + ctx.bag.error(DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING, "明细表必须指定 datasetId", { + path: `${path}/datasetId`, + ...where, + }) + } else if (!ctx.datasetIds.has(datasetId)) { + ctx.bag.error( + DIAGNOSTIC_CODES.REF_DANGLING_DATASET, + `明细表引用了不存在的数据集 "${datasetId}"`, + { path: `${path}/datasetId`, ...where }, + ) + } + const columns = record.columns + if (!Array.isArray(columns)) { + ctx.bag.error(DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING, "明细表必须包含 columns 数组", { + path: `${path}/columns`, + ...where, + }) + } else { + columns.forEach((column, columnIndex) => { + const columnPath = `${path}/columns/${columnIndex}` + if (!isPlainObject(column)) return + const columnRecord = column as Record + const columnWidth = columnRecord.width + if (typeof columnWidth !== "number" || !Number.isFinite(columnWidth) || columnWidth <= 0) { + ctx.bag.error( + DIAGNOSTIC_CODES.SCHEMA_RANGE_INVALID, + "表格列宽必须是正数", + { path: `${columnPath}/width`, ...where }, + ) + } + if (Array.isArray(columnRecord.children)) { + validateElements(columnRecord.children, `${columnPath}/children`, ctx) + } + }) + } + } + }) +} + +/** 检测样式继承环,返回所有环。 */ +export function detectStyleCycles( + styles: Record, +): string[][] { + const cycles: string[][] = [] + const state = new Map() + + const visit = (id: string, stack: string[]): void => { + const status = state.get(id) + if (status === "done") return + if (status === "visiting") { + const start = stack.indexOf(id) + cycles.push(start >= 0 ? stack.slice(start).concat(id) : [...stack, id]) + return + } + state.set(id, "visiting") + const style = styles[id] + if (isPlainObject(style)) { + const parent = (style as Record).extends + if (typeof parent === "string" && parent.length > 0) { + visit(parent, [...stack, id]) + } + } + state.set(id, "done") + } + + for (const id of Object.keys(styles)) visit(id, []) + return cycles +} diff --git a/code/one-designer/packages/report-core/src/schema/version.ts b/code/one-designer/packages/report-core/src/schema/version.ts new file mode 100644 index 00000000..134bf405 --- /dev/null +++ b/code/one-designer/packages/report-core/src/schema/version.ts @@ -0,0 +1,80 @@ +/** + * Schema 版本与迁移契约(对应设计文档 §12)。 + * + * - schemaVersion 使用单调递增整数。 + * - 反序列化顺序:解析 JSON → 校验结构 → 执行迁移 → 规范化默认值。 + * - 迁移函数按版本逐步执行,不允许跨版本直接修改内部对象。 + */ + +/** 当前 schema 版本。任何破坏性模型变更都必须递增此值并补齐迁移函数。 */ +export const CURRENT_SCHEMA_VERSION = 1 + +/** 首个受支持的 schema 版本。 */ +export const MIN_SCHEMA_VERSION = 1 + +/** + * 一次版本迁移。 + * + * from → to 必须是相邻版本(to === from + 1),由 migrate 流程强制校验, + * 保证“逐步执行、不跨版本直接改内部对象”。 + */ +export interface Migration { + from: number + to: number + description: string + /** + * 迁移函数。 + * + * 输入输出都是「未知形状的普通对象」,因为迁移发生在结构校验之前, + * 此时还不能假定数据符合当前模型。迁移函数必须容错:字段缺失时跳过, + * 而不是抛异常。 + */ + migrate(document: Record): Record +} + +/** + * 迁移注册表。 + * + * 目前只有 v1,故为空。新增版本时在此追加,例如: + * + * { + * from: 1, + * to: 2, + * description: "把 page.margin 从 px 改为 mm", + * migrate: (doc) => { ...; return doc }, + * } + */ +export const MIGRATIONS: readonly Migration[] = Object.freeze([]) + +/** 按起始版本取出需要依次执行的迁移链。 */ +export function planMigrations( + from: number, + to: number, + migrations: readonly Migration[] = MIGRATIONS, +): Migration[] { + if (from === to) return [] + if (from > to) { + throw new RangeError(`不支持从 v${from} 降级到 v${to}`) + } + const plan: Migration[] = [] + let cursor = from + while (cursor < to) { + const step = migrations.find((item) => item.from === cursor) + if (!step) { + throw new RangeError(`缺少 v${cursor} → v${cursor + 1} 的迁移函数`) + } + if (step.to !== step.from + 1) { + throw new RangeError( + `迁移必须逐版本执行:v${step.from} → v${step.to} 不是相邻版本`, + ) + } + plan.push(step) + cursor = step.to + } + return plan +} + +/** 判断给定版本号是否为受支持的整数版本。 */ +export function isValidSchemaVersion(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= MIN_SCHEMA_VERSION +} diff --git a/code/one-designer/packages/report-core/src/serialization/deserialize.ts b/code/one-designer/packages/report-core/src/serialization/deserialize.ts new file mode 100644 index 00000000..8f7acde9 --- /dev/null +++ b/code/one-designer/packages/report-core/src/serialization/deserialize.ts @@ -0,0 +1,223 @@ +/** + * 反序列化(对应设计文档 §12)。 + * + * 固定流程: + * + * deserialize(json) + * → parse 解析 JSON + * → validate 校验结构 + * → migrate 按版本逐步迁移 + * → normalize 规范化默认值 + * + * 关键取舍:校验失败时仍尽力返回一份可用的文档,并把问题放进 diagnostics。 + * 这是 §13 的要求——插件缺失、字段缺失等问题应可定位,而不应阻止 + * 整个文档被读取。只有连根结构都不成立时才返回 ok: false。 + */ +import type { Diagnostic } from "../model/common.js" +import type { ReportDocument } from "../model/report.js" +import { DIAGNOSTIC_CODES, error } from "../diagnostics/diagnostic.js" +import { isPlainObject } from "../utils/deep.js" +import { validateStructure, validateDocument } from "../schema/validation.js" +import { normalize } from "../document/normalize.js" +import { migrate } from "./migrate.js" +import { CURRENT_SCHEMA_VERSION } from "../schema/version.js" + +/** 反序列化选项。 */ +export interface DeserializeOptions { + /** 已注册的插件元素类型,用于校验元素 type 是否合法。 */ + pluginElementTypes?: ReadonlySet + /** + * 结构校验失败时是否仍然继续尝试规范化。 + * + * 默认 true:尽量把可读的内容还给用户,同时汇报全部问题。 + */ + lenient?: boolean +} + +/** 反序列化结果。 */ +export interface DeserializeResult { + ok: boolean + document: ReportDocument | null + diagnostics: Diagnostic[] + /** 实际执行的迁移步骤。 */ + migrationsApplied: string[] + /** 迁移前的 schema 版本。 */ + sourceVersion: number | null +} + +/** 从 JSON 字符串反序列化报表文档。 */ +export function deserialize( + json: string, + options: DeserializeOptions = {}, +): DeserializeResult { + let parsed: unknown + try { + parsed = JSON.parse(json) + } catch (cause) { + return { + ok: false, + document: null, + diagnostics: [ + error(DIAGNOSTIC_CODES.SCHEMA_INVALID, `JSON 解析失败:${(cause as Error).message}`, { + path: "/", + }), + ], + migrationsApplied: [], + sourceVersion: null, + } + } + return deserializeValue(parsed, options) +} + +/** 从已解析的普通对象反序列化报表文档。 */ +export function deserializeValue( + input: unknown, + options: DeserializeOptions = {}, +): DeserializeResult { + const { lenient = true } = options + const diagnostics: Diagnostic[] = [] + + if (!isPlainObject(input)) { + return { + ok: false, + document: null, + diagnostics: [ + error(DIAGNOSTIC_CODES.SCHEMA_INVALID, "文档根节点必须是普通对象", { path: "/" }), + ], + migrationsApplied: [], + sourceVersion: null, + } + } + + const sourceVersion = + typeof input.schemaVersion === "number" ? input.schemaVersion : null + + // 步骤 1:结构校验(在迁移之前,因为迁移函数假定输入形状基本可用) + const structureDiagnostics = validateStructure(input) + diagnostics.push(...structureDiagnostics) + + const structureBroken = structureDiagnostics.some((item) => item.severity === "error") + if (structureBroken && !lenient) { + return { + ok: false, + document: null, + diagnostics, + migrationsApplied: [], + sourceVersion, + } + } + + // 步骤 2:迁移 + let working: unknown = input + let migrationsApplied: string[] = [] + if (sourceVersion !== null && sourceVersion < CURRENT_SCHEMA_VERSION) { + const migration = migrate(input) + diagnostics.push(...migration.diagnostics) + migrationsApplied = migration.applied + working = migration.document + if (migration.diagnostics.some((item) => item.severity === "error")) { + return { + ok: false, + document: null, + diagnostics, + migrationsApplied, + sourceVersion, + } + } + } else if (sourceVersion !== null && sourceVersion > CURRENT_SCHEMA_VERSION) { + diagnostics.push( + error( + DIAGNOSTIC_CODES.SCHEMA_VERSION_UNKNOWN, + `文档版本 v${sourceVersion} 高于当前支持的 v${CURRENT_SCHEMA_VERSION},拒绝读取以避免数据损坏`, + { path: "/schemaVersion" }, + ), + ) + return { + ok: false, + document: null, + diagnostics, + migrationsApplied, + sourceVersion, + } + } + + // 步骤 3:规范化 + let document: ReportDocument + try { + document = normalize(working as ReportDocument) + } catch (cause) { + diagnostics.push( + error(DIAGNOSTIC_CODES.SCHEMA_INVALID, `文档规范化失败:${(cause as Error).message}`, { + path: "/", + }), + ) + return { + ok: false, + document: null, + diagnostics, + migrationsApplied, + sourceVersion, + } + } + + // 步骤 4:完整校验(引用完整性等),产出可定位诊断 + const validationOptions = options.pluginElementTypes + ? { pluginElementTypes: options.pluginElementTypes } + : {} + const fullDiagnostics = validateDocument(document, validationOptions) + // 结构与引用问题在迁移后可能已被修复,这里只补充去重后仍存在的问题 + for (const diagnostic of fullDiagnostics) { + if (!diagnostics.some((existing) => sameDiagnostic(existing, diagnostic))) { + diagnostics.push(diagnostic) + } + } + + return { + ok: !diagnostics.some((item) => item.severity === "error"), + document, + diagnostics, + migrationsApplied, + sourceVersion, + } +} + +function sameDiagnostic(a: Diagnostic, b: Diagnostic): boolean { + return a.code === b.code && a.path === b.path && a.elementId === b.elementId +} + +/** + * 宽松读取:无论有多少诊断都尽量返回文档。 + * + * 便于宿主做「打开旧文件」这类容错场景,配合 diagnostics 提示用户。 + */ +export function deserializeLeniently( + json: string, + options: Omit = {}, +): DeserializeResult { + return deserialize(json, { ...options, lenient: true }) +} + +/** 严格读取:任何错误级诊断都视为失败。 */ +export function deserializeStrictly( + json: string, + options: Omit = {}, +): DeserializeResult { + return deserialize(json, { ...options, lenient: false }) +} + +/** 便捷函数:只想要文档,不需要诊断时使用。失败返回 null。 */ +export function tryDeserialize( + json: string, + options: DeserializeOptions = {}, +): ReportDocument | null { + return deserialize(json, options).document +} + +/** 汇总诊断信息为一行摘要,便于日志。 */ +export function summarizeDiagnostics(diagnostics: readonly Diagnostic[]): string { + const errors = diagnostics.filter((item) => item.severity === "error").length + const warnings = diagnostics.filter((item) => item.severity === "warning").length + const infos = diagnostics.filter((item) => item.severity === "info").length + if (diagnostics.length === 0) return "无诊断信息" + return `共 ${diagnostics.length} 条诊断(错误 ${errors}、警告 ${warnings}、提示 ${infos})` +} diff --git a/code/one-designer/packages/report-core/src/serialization/migrate.ts b/code/one-designer/packages/report-core/src/serialization/migrate.ts new file mode 100644 index 00000000..4cf34c09 --- /dev/null +++ b/code/one-designer/packages/report-core/src/serialization/migrate.ts @@ -0,0 +1,159 @@ +/** + * 版本迁移(对应设计文档 §12)。 + * + * 要求: + * - 迁移函数按版本逐步执行,不允许跨版本直接修改内部对象。 + * - 迁移发生在结构校验之前(见 §12 的流程),因此必须容错: + * 字段缺失时跳过,而不是抛异常。 + */ +import type { Diagnostic } from "../model/common.js" +import { DIAGNOSTIC_CODES, warning, error } from "../diagnostics/diagnostic.js" +import { + CURRENT_SCHEMA_VERSION, + MIGRATIONS, + isValidSchemaVersion, + planMigrations, + type Migration, +} from "../schema/version.js" +import { isPlainObject } from "../utils/deep.js" + +/** 迁移结果。 */ +export interface MigrateResult { + /** 迁移后的对象。迁移失败时返回原对象。 */ + document: Record + /** 迁移前的版本。 */ + fromVersion: number + /** 迁移后的版本。 */ + toVersion: number + /** 实际执行的迁移步骤描述。 */ + applied: string[] + diagnostics: Diagnostic[] +} + +/** + * 把文档对象迁移到目标版本。 + * + * @param input 任意形状的文档对象 + * @param targetVersion 目标版本,缺省为当前版本 + */ +export function migrate( + input: unknown, + targetVersion: number = CURRENT_SCHEMA_VERSION, + migrations: readonly Migration[] = MIGRATIONS, +): MigrateResult { + const diagnostics: Diagnostic[] = [] + const applied: string[] = [] + + if (!isPlainObject(input)) { + diagnostics.push( + error(DIAGNOSTIC_CODES.SCHEMA_INVALID, "文档根节点必须是普通对象,无法执行迁移", { + path: "/", + }), + ) + return { + document: {}, + fromVersion: 0, + toVersion: targetVersion, + applied, + diagnostics, + } + } + + const document: Record = { ...input } + const rawVersion = document.schemaVersion + + if (!isValidSchemaVersion(rawVersion)) { + diagnostics.push( + error( + DIAGNOSTIC_CODES.SCHEMA_VERSION_MISSING, + `无法迁移:schemaVersion 缺失或非法(${JSON.stringify(rawVersion)})`, + { path: "/schemaVersion" }, + ), + ) + return { + document, + fromVersion: 0, + toVersion: targetVersion, + applied, + diagnostics, + } + } + + const fromVersion = rawVersion + + if (fromVersion === targetVersion) { + return { document, fromVersion, toVersion: targetVersion, applied, diagnostics } + } + + if (fromVersion > targetVersion) { + diagnostics.push( + error( + DIAGNOSTIC_CODES.SCHEMA_VERSION_UNKNOWN, + `文档 schemaVersion 为 ${fromVersion},高于当前支持的 ${targetVersion}。` + + "请升级运行环境后再打开该文档。", + { path: "/schemaVersion" }, + ), + ) + return { document, fromVersion, toVersion: fromVersion, applied, diagnostics } + } + + let plan: Migration[] + try { + plan = planMigrations(fromVersion, targetVersion, migrations) + } catch (cause) { + diagnostics.push( + error( + DIAGNOSTIC_CODES.SCHEMA_MIGRATION_FAILED, + `无法规划迁移路径:${(cause as Error).message}`, + { path: "/schemaVersion" }, + ), + ) + return { document, fromVersion, toVersion: fromVersion, applied, diagnostics } + } + + let current = document + for (const step of plan) { + try { + const next = step.migrate(current) + if (!isPlainObject(next)) { + throw new TypeError("迁移函数必须返回普通对象") + } + current = next + current.schemaVersion = step.to + applied.push(`v${step.from} → v${step.to}:${step.description}`) + } catch (cause) { + diagnostics.push( + error( + DIAGNOSTIC_CODES.SCHEMA_MIGRATION_FAILED, + `迁移 v${step.from} → v${step.to} 失败:${(cause as Error).message}`, + { path: "/" }, + ), + ) + return { document: current, fromVersion, toVersion: step.from, applied, diagnostics } + } + } + + if (applied.length > 0) { + diagnostics.push( + warning( + DIAGNOSTIC_CODES.SCHEMA_MIGRATION_FAILED, + `文档已从 v${fromVersion} 迁移到 v${targetVersion},共 ${applied.length} 步`, + ), + ) + } + + return { + document: current, + fromVersion, + toVersion: targetVersion, + applied, + diagnostics, + } +} + +/** 判断给定文档是否需要迁移。 */ +export function needsMigration(input: unknown): boolean { + if (!isPlainObject(input)) return false + const version = (input as Record).schemaVersion + return isValidSchemaVersion(version) && version < CURRENT_SCHEMA_VERSION +} diff --git a/code/one-designer/packages/report-core/src/serialization/serialize.ts b/code/one-designer/packages/report-core/src/serialization/serialize.ts new file mode 100644 index 00000000..1b3019ad --- /dev/null +++ b/code/one-designer/packages/report-core/src/serialization/serialize.ts @@ -0,0 +1,90 @@ +/** + * 序列化(对应设计文档 §12)。 + * + * 要求:序列化结果保持稳定排序,便于版本控制和差异比较。 + * 因此输出前会做 sortKeysDeep,保证同一份文档永远产出逐字节一致的 JSON。 + */ +import type { ReportDocument } from "../model/report.js" +import { sortKeysDeep, isPlainData } from "../utils/deep.js" +import { CURRENT_SCHEMA_VERSION } from "../schema/version.js" +import { normalize } from "../document/normalize.js" + +/** 序列化选项。 */ +export interface SerializeOptions { + /** + * 是否美化输出。 + * + * 默认 false,产出紧凑 JSON,适合作为传输格式; + * 存入版本库时建议开启,便于 diff。 + */ + pretty?: boolean + /** 缩进字符数,仅在 pretty 为 true 时生效。 */ + indent?: number + /** 是否按键名排序。默认 true,保证稳定输出。 */ + stable?: boolean +} + +/** + * 把文档序列化为 JSON 字符串。 + * + * 序列化前先 normalize,保证输出始终是当前 schema 的完整结构, + * 避免把半成品或含缺省字段缺失的文档写进存储。 + */ +export function serialize( + document: ReportDocument, + options: SerializeOptions = {}, +): string { + // 先校验输入的纯数据性,再规范化。 + // 顺序很重要:normalize 会按字段重建文档,顺带丢弃未知字段, + // 因此若先规范化,混入的函数 / DOM 节点会被静默吞掉而无法被发现。 + assertPlainData(document) + const normalized = normalize(document) + const withVersion: ReportDocument = { + ...normalized, + schemaVersion: CURRENT_SCHEMA_VERSION, + } + return serializeValue(withVersion, options) +} + +/** + * 断言值是纯数据(§3.5)。 + * + * 文档 JSON 中不能保存函数、DOM 节点或运行时对象。 + */ +function assertPlainData(value: unknown): void { + if (!isPlainData(value)) { + throw new TypeError( + "文档中包含不可序列化的值(函数、DOM 节点或运行时对象)。" + + "请检查是否误把编辑器状态写入了文档(见架构原则 §3.2 / §3.5)。", + ) + } +} + +/** 序列化任意纯数据值。 */ +export function serializeValue(value: unknown, options: SerializeOptions = {}): string { + const { pretty = false, indent = 2, stable = true } = options + + if (!isPlainData(value)) { + throw new TypeError( + "文档中包含不可序列化的值(函数、DOM 节点或运行时对象)。" + + "请检查是否误把编辑器状态写入了文档(见架构原则 §3.2 / §3.5)。", + ) + } + + const prepared = stable ? sortKeysDeep(value) : value + return pretty ? JSON.stringify(prepared, null, indent) : JSON.stringify(prepared) +} + +/** + * 把文档转为可安全写盘的普通对象(深拷贝 + 稳定排序)。 + * + * 适用于 host 需要自己控制 JSON 化时机的场景(如 Electron IPC)。 + */ +export function toPlainObject(document: ReportDocument): Record { + const normalized = normalize(document) + const withVersion: ReportDocument = { + ...normalized, + schemaVersion: CURRENT_SCHEMA_VERSION, + } + return sortKeysDeep(JSON.parse(JSON.stringify(withVersion))) as Record +} diff --git a/code/one-designer/packages/report-core/src/utils/deep.ts b/code/one-designer/packages/report-core/src/utils/deep.ts new file mode 100644 index 00000000..058fcff1 --- /dev/null +++ b/code/one-designer/packages/report-core/src/utils/deep.ts @@ -0,0 +1,112 @@ +/** + * 深拷贝与对象工具。 + * + * 约束(§3.2 / §3.5):文档必须是纯数据,因此这里只需要处理 JSON + * 可序列化类型,不处理函数、Date、Map、Set、DOM 节点。遇到这些类型 + * 会被显式拒绝,避免运行时对象混入文档。 + */ + +/** 判断是否为普通对象(不含数组、null)。 */ +export function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +/** 判断是否为纯数据(可安全序列化)。 */ +export function isPlainData(value: unknown): boolean { + if (value === null) return true + const type = typeof value + if (type === "string" || type === "number" || type === "boolean") return true + if (type === "function" || type === "symbol" || type === "bigint" || type === "undefined") { + return false + } + if (Array.isArray(value)) return value.every(isPlainData) + if (isPlainObject(value)) return Object.values(value).every(isPlainData) + return false +} + +/** + * 深拷贝纯数据。 + * + * 故意不使用 structuredClone,以保证在全部目标环境(含较老的 Node/Electron) + * 中行为一致,并且在拷贝过程中顺手校验纯数据约束。 + */ +export function deepClone(value: T): T { + if (value === null || typeof value !== "object") return value + if (Array.isArray(value)) { + return value.map((item) => deepClone(item)) as unknown as T + } + if (isPlainObject(value)) { + const out: Record = {} + for (const [key, item] of Object.entries(value)) { + out[key] = deepClone(item) + } + return out as T + } + throw new TypeError( + `deepClone 只接受纯数据,收到 ${Object.prototype.toString.call(value)}`, + ) +} + +/** 深比较两个纯数据是否相等。 */ +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + if (typeof a !== typeof b) return false + if (a === null || b === null) return false + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false + return a.every((item, index) => deepEqual(item, b[index])) + } + if (isPlainObject(a) && isPlainObject(b)) { + const keysA = Object.keys(a) + const keysB = Object.keys(b) + if (keysA.length !== keysB.length) return false + return keysA.every((key) => deepEqual(a[key], b[key])) + } + return false +} + +/** + * 按键名递归排序对象键,保证序列化结果稳定(§12)。 + * + * 数组顺序保持不变,只有对象键参与排序。 + */ +export function sortKeysDeep(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => sortKeysDeep(item)) as unknown as T + } + if (isPlainObject(value)) { + const out: Record = {} + for (const key of Object.keys(value).sort()) { + out[key] = sortKeysDeep(value[key]) + } + return out as T + } + return value +} + +/** 移除对象中的 undefined 值,返回新对象。 */ +export function omitUndefined>(value: T): T { + const out: Record = {} + for (const [key, item] of Object.entries(value)) { + if (item !== undefined) out[key] = item + } + return out as T +} + +/** 深合并,source 中显式定义的值覆盖 target。undefined 不覆盖。 */ +export function deepMerge(target: T, source: unknown): T { + if (!isPlainObject(target) || !isPlainObject(source)) { + return (source === undefined ? target : source) as T + } + const out: Record = { ...target } + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue + const current = out[key] + out[key] = isPlainObject(current) && isPlainObject(value) + ? deepMerge(current, value) + : value + } + return out as T +} diff --git a/code/one-designer/packages/report-core/src/utils/id.ts b/code/one-designer/packages/report-core/src/utils/id.ts new file mode 100644 index 00000000..ed84117f --- /dev/null +++ b/code/one-designer/packages/report-core/src/utils/id.ts @@ -0,0 +1,97 @@ +/** + * ID 生成(Â?6 阶段 1 要求「完成基础命令å’?ID 生成」)ã€? * + * 要求ï¼? * - 不依èµ?crypto/浏览å™?API,可åœ?Browser / Node / Electron / Server / CLI 复用(Â?.1)ã€? * - 同一进程内保证单调递增,避免同一毫秒内批量创建元素时碰撞ã€? * - 生成结果只包含小写字母和数字,便于在 JSON 中做稳定排序(Â?2)ã€? */ + +const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" +const RANDOM_LENGTH = 10 + +let counter = Math.floor(Math.random() * 0xffffff) + +function randomChars(length: number): string { + let out = "" + for (let i = 0; i < length; i += 1) { + out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)] + } + return out +} + +function counterChars(): string { + counter = (counter + 1) % 0xffffff + let value = counter + let out = "" + for (let i = 0; i < 5; i += 1) { + out = ALPHABET[value % ALPHABET.length]! + out + value = Math.floor(value / ALPHABET.length) + } + return out +} + +export interface IdOptions { + /** ID 前缀,例å¦?"el"ã€?sec",便于在大文档中肉眼辨认ã€?*/ + prefix?: string +} + +/** + * 生成一ä¸?IDã€? * + * 形如 `el_k3f9a2x8qp_00a1b`:前缀 + 随机æ®?+ 进程内单调计数器ã€? */ +export function createId(options: IdOptions = {}): string { + const { prefix } = options + const body = `${randomChars(RANDOM_LENGTH)}_${counterChars()}` + return prefix ? `${prefix}_${body}` : body +} + +/** 元素 IDã€?*/ +export function createElementId(): string { + return createId({ prefix: "el" }) +} + +/** 区域 IDã€?*/ +export function createSectionId(): string { + return createId({ prefix: "sec" }) +} + +/** 文档 IDã€?*/ +export function createDocumentId(): string { + return createId({ prefix: "rpt" }) +} + +/** 数据é›?IDã€?*/ +export function createDatasetId(): string { + return createId({ prefix: "ds" }) +} + +/** 数据æº?IDã€?*/ +export function createDataSourceId(): string { + return createId({ prefix: "src" }) +} + +/** 参数 IDã€?*/ +export function createParameterId(): string { + return createId({ prefix: "p" }) +} + +/** 表格åˆ?IDã€?*/ +export function createColumnId(): string { + return createId({ prefix: "col" }) +} + +/** 样式 IDã€?*/ +export function createStyleId(): string { + return createId({ prefix: "sty" }) +} + +/** 判断字符串是否为合法 ID(非空且不含空白)ã€?*/ +export function isValidId(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && !/\s/.test(value) +} + +/** + * 在给定集合中生成一个不冲突çš?IDã€? * + * 用于 createDefault 之类需要保证唯一性的场景ã€? */ +export function createUniqueId(existing: ReadonlySet, options?: IdOptions): string { + let candidate = createId(options) + while (existing.has(candidate)) { + candidate = createId(options) + } + return candidate +} diff --git a/code/one-designer/packages/report-core/tests/command.test.ts b/code/one-designer/packages/report-core/tests/command.test.ts new file mode 100644 index 00000000..1c04bf8a --- /dev/null +++ b/code/one-designer/packages/report-core/tests/command.test.ts @@ -0,0 +1,449 @@ +/** + * 命令、事务与撤销重做测试(对应 §4.5、§3.2)。 + * + * 关键断言:撤销栈属于编辑器状态,不进入文档(§3.2)。 + */ +import { describe, expect, it } from "vitest" +import { + CommandInvoker, + createCommand, + createInvoker, + failure, + isUndoable, + success, +} from "../src/command/command.js" +import { + EditSession, + History, + createEditSession, + hasErrorDiagnostics, +} from "../src/command/history.js" +import { createDocument } from "../src/document/create.js" +import type { Command } from "../src/command/command.js" +import type { ReportDocument } from "../src/model/report.js" + +/** 测试用:给 detail 区域加一个文本元素。 */ +const addText = createCommand<{ text: string }>( + "addText", + (document, payload) => { + const next = JSON.parse(JSON.stringify(document)) as ReportDocument + const detail = next.sections.find((s) => s.kind === "detail")! + detail.children.push({ + id: `el-${payload.text}`, + type: "text", + frame: { x: 0, y: 0, width: 10, height: 5 }, + props: { text: payload.text }, + }) + return next + }, +) + +/** 测试用:改名。 */ +const rename = createCommand<{ name: string }>("rename", (document, payload) => { + const next = JSON.parse(JSON.stringify(document)) as ReportDocument + next.name = payload.name + return next +}) + +describe("createCommand", () => { + it("返回新文档而不修改原文档", () => { + const doc = createDocument({ name: "原始" }) + const result = rename.execute({ name: "新名" }, { document: doc }) + expect(result.document?.name).toBe("新名") + expect(doc.name).toBe("原始") + }) + + it("支持返回 CommandResult", () => { + const cmd = createCommand("noop", (document) => success(document)) + const result = cmd.execute(undefined, { document: createDocument() }) + expect(result.document).not.toBeNull() + expect(result.diagnostics).toEqual([]) + }) + + it("默认可撤销", () => { + expect(isUndoable(rename as Command)).toBe(true) + }) + + it("可声明不可撤销", () => { + const cmd = createCommand("load", (d) => d, { undoable: false }) + expect(isUndoable(cmd as Command)).toBe(false) + }) + + it("可声明合并键", () => { + const cmd = createCommand("drag", (d) => d, { mergeKey: "drag" }) + expect(cmd.mergeKey).toBe("drag") + }) + + it("failure 构造失败结果", () => { + const result = failure({ severity: "error", code: "x", message: "失败" }) + expect(result.document).toBeNull() + expect(result.diagnostics).toHaveLength(1) + }) + + it("hasErrorDiagnostics 判断错误", () => { + expect(hasErrorDiagnostics([{ severity: "warning", code: "w", message: "m" }])).toBe(false) + expect(hasErrorDiagnostics([{ severity: "error", code: "e", message: "m" }])).toBe(true) + }) +}) + +describe("CommandInvoker", () => { + it("执行命令后更新内部文档", () => { + const invoker = createInvoker(createDocument({ name: "A" })) + invoker.run(rename, { name: "B" }) + expect(invoker.getDocument().name).toBe("B") + }) + + it("失败时文档不变", () => { + const failing = createCommand("fail", () => + failure({ severity: "error", code: "x", message: "boom" }), + ) + const invoker = new CommandInvoker(createDocument({ name: "A" })) + const result = invoker.run(failing, undefined) + expect(result.document).toBeNull() + expect(invoker.getDocument().name).toBe("A") + }) + + it("runWithSnapshot 返回变更前快照", () => { + const invoker = createInvoker(createDocument({ name: "A" })) + const { before, result } = invoker.runWithSnapshot(rename, { name: "B" }) + expect(before?.name).toBe("A") + expect(result.document?.name).toBe("B") + }) + + it("不可撤销命令不产生快照", () => { + const cmd = createCommand("noop", (d) => d, { undoable: false }) + const invoker = createInvoker(createDocument()) + const { before } = invoker.runWithSnapshot(cmd, undefined) + expect(before).toBeNull() + }) +}) + +describe("History", () => { + it("记录后可撤销重做", () => { + const history = new History() + const a = createDocument({ name: "A" }) + const b = createDocument({ name: "B" }) + expect(history.record(a, b, "改名")).toBe(true) + expect(history.canUndo).toBe(true) + + const undone = history.undo() + expect(undone?.name).toBe("A") + expect(history.canRedo).toBe(true) + + const redone = history.redo() + expect(redone?.name).toBe("B") + }) + + it("相同内容不记录(避免空撤销步)", () => { + const history = new History() + const a = createDocument({ name: "A" }) + // 用同一份文档的深拷贝作为 after,保证内容(含生成的 ID)完全一致。 + // 注意不能新建 createDocument,因为它会生成新的随机 ID。 + const b = JSON.parse(JSON.stringify(a)) as ReportDocument + expect(history.record(a, b, "无变化")).toBe(false) + expect(history.canUndo).toBe(false) + }) + + it("撤销后新操作清空重做栈", () => { + const history = new History() + const a = createDocument({ name: "A" }) + const b = createDocument({ name: "B" }) + const c = createDocument({ name: "C" }) + history.record(a, b, "第一步") + history.undo() + history.record(a, c, "新分支") + expect(history.canRedo).toBe(false) + expect(history.peekUndoLabel()).toBe("新分支") + }) + + it("空栈时撤销返回 null", () => { + expect(new History().undo()).toBeNull() + expect(new History().redo()).toBeNull() + }) + + it("按 mergeKey 与时间窗合并连续操作", () => { + const history = new History({ mergeWindow: 10_000 }) + const a = createDocument({ name: "A" }) + const b = createDocument({ name: "B" }) + const c = createDocument({ name: "C" }) + history.record(a, b, "拖拽", { mergeKey: "drag" }) + history.record(b, c, "拖拽", { mergeKey: "drag" }) + expect(history.undoDepth).toBe(1) + expect(history.undo()?.name).toBe("A") + }) + + it("mergeKey 不同则不合并", () => { + const history = new History({ mergeWindow: 10_000 }) + const a = createDocument({ name: "A" }) + const b = createDocument({ name: "B" }) + const c = createDocument({ name: "C" }) + history.record(a, b, "拖拽", { mergeKey: "drag" }) + history.record(b, c, "缩放", { mergeKey: "resize" }) + expect(history.undoDepth).toBe(2) + }) + + it("超过上限时丢弃最早记录", () => { + const history = new History({ limit: 2 }) + const docs = ["A", "B", "C", "D"].map((name) => createDocument({ name })) + for (let i = 0; i < docs.length - 1; i += 1) { + history.record(docs[i]!, docs[i + 1]!, `步骤${i}`) + } + expect(history.undoDepth).toBe(2) + }) + + it("事务把多次变更合并为一步", () => { + const history = new History() + const a = createDocument({ name: "A" }) + history.setBase(a) + + const b = createDocument({ name: "B" }) + const c = createDocument({ name: "C" }) + + const { changed } = history.transaction("批量操作", () => { + history.updateCurrent(b) + history.updateCurrent(c) + }) + + expect(changed).toBe(true) + expect(history.undoDepth).toBe(1) + expect(history.undo()?.name).toBe("A") + }) + + it("事务内无实际变更则不记录", () => { + const history = new History() + const a = createDocument({ name: "A" }) + history.setBase(a) + const { changed } = history.transaction("空事务", () => { + history.updateCurrent(a) + }) + expect(changed).toBe(false) + expect(history.undoDepth).toBe(0) + }) + + it("嵌套事务并入外层", () => { + const history = new History() + const a = createDocument({ name: "A" }) + history.setBase(a) + const b = createDocument({ name: "B" }) + history.transaction("外层", () => { + history.transaction("内层", () => { + history.updateCurrent(b) + }) + }) + expect(history.undoDepth).toBe(1) + }) + + it("begin/commit 事务可跨调用累积,最终合并为一步", () => { + const history = new History() + history.setBase(createDocument({ name: "A" })) + + expect(history.inTransaction).toBe(false) + expect(history.beginTransaction("拖拽")).toBe(true) + expect(history.inTransaction).toBe(true) + + // 模拟拖拽过程中反复更新(每次都是一次事件回调) + history.updateCurrent(createDocument({ name: "B" })) + history.updateCurrent(createDocument({ name: "C" })) + + expect(history.commitTransaction()).toBe(true) + expect(history.inTransaction).toBe(false) + expect(history.undoDepth).toBe(1) + // 撤销直接回到事务开始前的 A,而不是中间态 B + expect(history.undo()?.name).toBe("A") + }) + + it("begin 事务期间不产生中间撤销记录", () => { + const history = new History() + history.setBase(createDocument({ name: "A" })) + history.beginTransaction("拖拽") + history.updateCurrent(createDocument({ name: "B" })) + history.updateCurrent(createDocument({ name: "C" })) + // 还没提交,撤销栈里不该有任何东西 + expect(history.undoDepth).toBe(0) + history.commitTransaction() + expect(history.undoDepth).toBe(1) + }) + + it("commit 无实际变更时返回 false 且不记录", () => { + const history = new History() + const a = createDocument({ name: "A" }) + history.setBase(a) + history.beginTransaction("空拖拽") + history.updateCurrent(a) + expect(history.commitTransaction()).toBe(false) + expect(history.undoDepth).toBe(0) + }) + + it("rollback 丢弃事务期间的变更", () => { + const history = new History() + history.setBase(createDocument({ name: "A" })) + history.beginTransaction("拖拽") + history.updateCurrent(createDocument({ name: "B" })) + + expect(history.rollbackTransaction()?.name).toBe("A") + expect(history.inTransaction).toBe(false) + expect(history.undoDepth).toBe(0) + }) + + it("事务进行中再 begin 返回 false(嵌套并入外层)", () => { + const history = new History() + history.setBase(createDocument({ name: "A" })) + expect(history.beginTransaction("外层")).toBe(true) + expect(history.beginTransaction("内层")).toBe(false) + history.updateCurrent(createDocument({ name: "B" })) + history.commitTransaction() + expect(history.undoDepth).toBe(1) + }) + + it("没有基准文档时 begin 返回 false", () => { + const history = new History() + expect(history.beginTransaction("拖拽")).toBe(false) + expect(history.inTransaction).toBe(false) + }) + + it("clear 清空历史", () => { + const history = new History() + history.record(createDocument({ name: "A" }), createDocument({ name: "B" }), "x") + history.clear() + expect(history.canUndo).toBe(false) + expect(history.canRedo).toBe(false) + }) + + it("describe 导出摘要", () => { + const history = new History() + history.record(createDocument({ name: "A" }), createDocument({ name: "B" }), "改名") + expect(history.describe().undo).toEqual(["改名"]) + }) + + it("peek 返回待撤销/重做的操作名", () => { + const history = new History() + history.record(createDocument({ name: "A" }), createDocument({ name: "B" }), "改名") + expect(history.peekUndoLabel()).toBe("改名") + history.undo() + expect(history.peekRedoLabel()).toBe("改名") + }) +}) + +describe("EditSession", () => { + it("执行命令并累积历史", () => { + const session = createEditSession(createDocument({ name: "A" })) + session.execute(rename, { name: "B" }) + expect(session.getDocument().name).toBe("B") + expect(session.history.canUndo).toBe(true) + }) + + it("撤销恢复上一状态", () => { + const session = createEditSession(createDocument({ name: "A" })) + session.execute(rename, { name: "B" }) + expect(session.undo()).toBe(true) + expect(session.getDocument().name).toBe("A") + }) + + it("重做恢复后续状态", () => { + const session = createEditSession(createDocument({ name: "A" })) + session.execute(rename, { name: "B" }) + session.undo() + expect(session.redo()).toBe(true) + expect(session.getDocument().name).toBe("B") + }) + + it("无可撤销时返回 false", () => { + const session = createEditSession(createDocument()) + expect(session.undo()).toBe(false) + expect(session.redo()).toBe(false) + }) + + it("失败命令不产生历史", () => { + const failing = createCommand("fail", () => + failure({ severity: "error", code: "x", message: "boom" }), + ) + const session = createEditSession(createDocument({ name: "A" })) + session.execute(failing, undefined) + expect(session.history.canUndo).toBe(false) + expect(session.getDocument().name).toBe("A") + }) + + it("多步操作依次撤销", () => { + const session = createEditSession(createDocument({ name: "A" })) + session.execute(rename, { name: "B" }) + session.execute(rename, { name: "C" }) + session.undo() + expect(session.getDocument().name).toBe("B") + session.undo() + expect(session.getDocument().name).toBe("A") + }) + + it("load 替换文档并清空历史", () => { + const session = createEditSession(createDocument({ name: "A" })) + session.execute(rename, { name: "B" }) + session.load(createDocument({ name: "全新" })) + expect(session.getDocument().name).toBe("全新") + expect(session.history.canUndo).toBe(false) + }) + + it("事务内多次编辑合并为一步撤销", () => { + const session = createEditSession(createDocument({ name: "A" })) + session.transaction("批量改名", (s) => { + s.applyWithinTransaction(rename, { name: "B" }) + s.applyWithinTransaction(rename, { name: "C" }) + }) + expect(session.getDocument().name).toBe("C") + session.undo() + expect(session.getDocument().name).toBe("A") + }) + + it("begin/commit 事务可跨多个事件回调累积为一步撤销", () => { + const session = createEditSession(createDocument({ name: "A" })) + + expect(session.inTransaction).toBe(false) + expect(session.beginTransaction("拖拽")).toBe(true) + expect(session.inTransaction).toBe(true) + + // 每次 applyWithinTransaction 相当于一次 mousemove 回调 + session.applyWithinTransaction(rename, { name: "B" }) + session.applyWithinTransaction(rename, { name: "C" }) + // 未提交前不应有撤销记录 + expect(session.history.canUndo).toBe(false) + + expect(session.commitTransaction()).toBe(true) + expect(session.inTransaction).toBe(false) + expect(session.getDocument().name).toBe("C") + + // 一次撤销回到事务开始前的 A + session.undo() + expect(session.getDocument().name).toBe("A") + }) + + it("begin/commit 事务无变更时不产生撤销记录", () => { + const session = createEditSession(createDocument({ name: "A" })) + session.beginTransaction("拖拽") + expect(session.commitTransaction()).toBe(false) + expect(session.history.canUndo).toBe(false) + }) + + it("会话修改不影响传入的原始文档", () => { + const original = createDocument({ name: "A" }) + const session = createEditSession(original) + session.execute(rename, { name: "B" }) + expect(original.name).toBe("A") + }) + + it("撤销不把编辑器状态写进文档(§3.2)", () => { + const session = createEditSession(createDocument({ name: "A" })) + session.execute(rename, { name: "B" }) + session.execute(rename, { name: "C" }) + const doc = session.getDocument() as unknown as Record + for (const key of ["undoStack", "redoStack", "history", "selection", "zoom", "scroll"]) { + expect(doc[key]).toBeUndefined() + } + }) +}) + +describe("EditSession 与 History 可复用性", () => { + it("EditSession 使用注入的 History 配置", () => { + const session = new EditSession(createDocument(), { limit: 1 }) + session.execute(rename, { name: "B" }) + session.execute(rename, { name: "C" }) + expect(session.history.undoDepth).toBe(1) + }) +}) diff --git a/code/one-designer/packages/report-core/tests/document.test.ts b/code/one-designer/packages/report-core/tests/document.test.ts new file mode 100644 index 00000000..57f80798 --- /dev/null +++ b/code/one-designer/packages/report-core/tests/document.test.ts @@ -0,0 +1,206 @@ +/** + * 文档创建、规范化与克隆测试(对应 §14 文档 schema 与默认值测试)。 + */ +import { describe, expect, it } from "vitest" +import { + countElements, + createDocument, + createDefaultSections, + duplicateDocument, + isDocumentEmpty, +} from "../src/document/create.js" +import { normalize } from "../src/document/normalize.js" +import { cloneElementWithNewIds, cloneDocument } from "../src/document/clone.js" +import { CURRENT_SCHEMA_VERSION } from "../src/schema/version.js" +import { createElementId } from "../src/utils/id.js" +import type { ReportElement } from "../src/model/element.js" +import type { TableElement } from "../src/model/table.js" + +describe("createDocument", () => { + it("产出满足当前 schema 版本的完整文档", () => { + const doc = createDocument({ name: "测试报表" }) + expect(doc.schemaVersion).toBe(CURRENT_SCHEMA_VERSION) + expect(doc.name).toBe("测试报表") + expect(doc.id).toBeTruthy() + expect(doc.page.paper).toBe("A4") + expect(doc.page.orientation).toBe("portrait") + expect(doc.sections).toHaveLength(3) + expect(doc.sections.map((s) => s.kind)).toEqual([ + "page-header", + "detail", + "page-footer", + ]) + }) + + it("缺省名称为未命名报表", () => { + expect(createDocument().name).toBe("未命名报表") + }) + + it("可覆盖页面设置", () => { + const doc = createDocument({ + page: { paper: "A5", orientation: "landscape", margin: { top: 5, right: 15, bottom: 15, left: 15 } }, + }) + expect(doc.page.paper).toBe("A5") + expect(doc.page.orientation).toBe("landscape") + expect(doc.page.margin.top).toBe(5) + // 未指定的边距回落到默认值 + expect(doc.page.margin.right).toBe(15) + }) + + it("新建文档为空", () => { + expect(isDocumentEmpty(createDocument())).toBe(true) + }) +}) + +describe("normalize", () => { + it("补齐缺失的 V1 区域", () => { + const doc = createDocument() + const stripped = normalize({ + ...doc, + sections: [{ id: "s1", kind: "detail", children: [] }], + }) + expect(stripped.sections).toHaveLength(3) + expect(stripped.sections.map((s) => s.kind)).toEqual([ + "page-header", + "detail", + "page-footer", + ]) + }) + + it("保留额外区域并附在 V1 区域之后", () => { + const doc = createDocument() + const withSummary = normalize({ + ...doc, + sections: [ + ...doc.sections, + { id: "sum1", kind: "summary", children: [] }, + ], + }) + expect(withSummary.sections).toHaveLength(4) + expect(withSummary.sections[3]?.kind).toBe("summary") + }) + + it("以字典键为准修正 style.id", () => { + const doc = createDocument() + const normalized = normalize({ + ...doc, + styles: { sty_a: { id: "不匹配的ID", fontSize: 12 } }, + }) + expect(normalized.styles.sty_a?.id).toBe("sty_a") + }) + + it("参数缺名时回落到 id", () => { + const doc = createDocument() + const normalized = normalize({ + ...doc, + parameters: { p1: { id: "p1", name: "" } as never }, + }) + expect(normalized.parameters.p1?.name).toBe("p1") + }) + + it("缺省页边距被补齐", () => { + const doc = createDocument() + const normalized = normalize({ + ...doc, + page: { paper: "A4", orientation: "portrait" } as never, + }) + expect(normalized.page.margin).toEqual({ top: 15, right: 15, bottom: 15, left: 15 }) + }) + + it("空名回落到未命名报表", () => { + const doc = createDocument() + expect(normalize({ ...doc, name: "" }).name).toBe("未命名报表") + }) +}) + +describe("clone", () => { + it("cloneDocument 深拷贝且保留 ID", () => { + const doc = createDocument({ name: "原始" }) + const copy = cloneDocument(doc) + expect(copy).toEqual(doc) + expect(copy).not.toBe(doc) + // 修改副本不影响原件 + copy.name = "副本" + expect(doc.name).toBe("原始") + }) + + it("cloneElementWithNewIds 为整棵子树重新分配 ID", () => { + const child: ReportElement = { + id: createElementId(), + type: "text", + frame: { x: 0, y: 0, width: 10, height: 5 }, + } + const parent: ReportElement = { + id: createElementId(), + type: "container", + frame: { x: 0, y: 0, width: 20, height: 20 }, + children: [child], + } + const copy = cloneElementWithNewIds(parent) + expect(copy.id).not.toBe(parent.id) + expect(copy.children?.[0]?.id).not.toBe(child.id) + // 结构保持一致 + expect(copy.type).toBe("container") + expect(copy.children).toHaveLength(1) + }) + + it("cloneElementWithNewIds 也处理表格列 ID", () => { + const table: TableElement = { + id: "t1", + type: "table", + frame: { x: 0, y: 0, width: 100, height: 30 }, + datasetId: "ds1", + columns: [{ id: "col1", width: 30 }], + } + const copy = cloneElementWithNewIds(table) + const columns = (copy as TableElement).columns + expect(columns[0]?.id).not.toBe("col1") + }) +}) + +describe("duplicateDocument", () => { + it("生成新 ID 与副本名称", () => { + const doc = createDocument({ name: "销售单" }) + const copy = duplicateDocument(doc) + expect(copy.id).not.toBe(doc.id) + expect(copy.name).toBe("销售单 副本") + }) + + it("可指定副本名称", () => { + const doc = createDocument({ name: "销售单" }) + expect(duplicateDocument(doc, { name: "新名称" }).name).toBe("新名称") + }) +}) + +describe("countElements", () => { + it("统计嵌套元素", () => { + const doc = createDocument() + doc.sections[1]!.children = [ + { + id: "a", + type: "container", + frame: { x: 0, y: 0, width: 10, height: 10 }, + children: [{ id: "b", type: "text", frame: { x: 0, y: 0, width: 5, height: 5 } }], + }, + ] + expect(countElements(doc)).toBe(2) + }) + + it("空文档为 0", () => { + expect(countElements(createDocument())).toBe(0) + }) +}) + +describe("createDefaultSections", () => { + it("生成互不相同的区域 ID", () => { + const sections = createDefaultSections() + const ids = new Set(sections.map((s) => s.id)) + expect(ids.size).toBe(3) + }) + + it("页眉页脚默认每页重复", () => { + const sections = createDefaultSections() + const header = sections.find((s) => s.kind === "page-header") + expect(header?.options?.repeatOnEachPage).toBe(true) + }) +}) diff --git a/code/one-designer/packages/report-core/tests/plugin.test.ts b/code/one-designer/packages/report-core/tests/plugin.test.ts new file mode 100644 index 00000000..c2622719 --- /dev/null +++ b/code/one-designer/packages/report-core/tests/plugin.test.ts @@ -0,0 +1,269 @@ +/** + * 插件契约与注册表测试(对应 §11、§14 的插件缺失与版本不兼容测试)。 + */ +import { describe, expect, it, vi } from "vitest" +import { + PluginRegistry, + createPluginRegistry, + reportRegisterResult, +} from "../src/plugin/registry.js" +import { isVersionCompatible } from "../src/plugin/plugin.js" +import { createDocument } from "../src/document/create.js" +import { DIAGNOSTIC_CODES, DiagnosticBag } from "../src/diagnostics/diagnostic.js" +import { createElementId } from "../src/utils/id.js" +import type { ReportPlugin } from "../src/plugin/plugin.js" +import type { ReportDocument } from "../src/model/report.js" + +function qrcodePlugin(overrides: Partial = {}): ReportPlugin { + return { + id: "plugin-qrcode", + version: "1.2.0", + elements: [ + { + type: "qrcode", + label: "二维码", + createDefault: (options) => ({ + id: options?.id ?? createElementId(), + type: "qrcode", + frame: options?.frame ?? { x: 0, y: 0, width: 20, height: 20 }, + props: { value: "https://example.com", errorLevel: "M" }, + }), + }, + ], + ...overrides, + } +} + +describe("PluginRegistry 注册", () => { + it("注册成功后可按类型取到元素插件", () => { + const registry = new PluginRegistry() + const result = registry.register(qrcodePlugin()) + expect(result.ok).toBe(true) + expect(registry.getElementPlugin("qrcode")?.type).toBe("qrcode") + }) + + it("缺少 id 的插件注册失败", () => { + const registry = new PluginRegistry() + const result = registry.register({ id: "", version: "1.0.0" }) + expect(result.ok).toBe(false) + expect(result.diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING)).toBe(true) + }) + + it("重复注册同 id 失败且不覆盖", () => { + const registry = new PluginRegistry() + registry.register(qrcodePlugin()) + const second = registry.register(qrcodePlugin({ version: "9.9.9" })) + expect(second.ok).toBe(false) + expect(second.diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.PLUGIN_ALREADY_REGISTERED)).toBe(true) + expect(registry.get("plugin-qrcode")?.version).toBe("1.2.0") + }) + + it("元素类型冲突时注册失败", () => { + const registry = new PluginRegistry() + registry.register(qrcodePlugin()) + const conflict = registry.register({ + id: "plugin-other", + version: "1.0.0", + elements: [ + { + type: "qrcode", + createDefault: () => ({ + id: "x", + type: "qrcode", + frame: { x: 0, y: 0, width: 1, height: 1 }, + }), + }, + ], + }) + expect(conflict.ok).toBe(false) + }) + + it("registerAll 汇总结果", () => { + const registry = new PluginRegistry() + const result = registry.registerAll([qrcodePlugin(), { id: "p2", version: "1.0.0" }]) + expect(result.ok).toBe(true) + expect(registry.list()).toHaveLength(2) + }) + + it("setup 钩子被调用并收到上下文", () => { + const setup = vi.fn() + const registry = new PluginRegistry() + registry.register(qrcodePlugin({ setup })) + expect(setup).toHaveBeenCalledTimes(1) + expect(setup.mock.calls[0]?.[0]).toMatchObject({ + pluginId: "plugin-qrcode", + pluginVersion: "1.2.0", + }) + }) + + it("unregister 移除插件及其元素类型", () => { + const registry = new PluginRegistry() + registry.register(qrcodePlugin()) + expect(registry.unregister("plugin-qrcode")).toBe(true) + expect(registry.getElementPlugin("qrcode")).toBeUndefined() + expect(registry.unregister("plugin-qrcode")).toBe(false) + }) + + it("clear 清空全部注册", () => { + const registry = createPluginRegistry([qrcodePlugin()]) + registry.clear() + expect(registry.list()).toHaveLength(0) + expect(registry.elementTypes().size).toBe(0) + }) + + it("elementTypes 与 containerTypes", () => { + const registry = createPluginRegistry([ + { + id: "p", + version: "1.0.0", + elements: [ + { + type: "leaf", + createDefault: () => ({ id: "a", type: "leaf", frame: { x: 0, y: 0, width: 1, height: 1 } }), + }, + { + type: "box", + isContainer: true, + createDefault: () => ({ id: "b", type: "box", frame: { x: 0, y: 0, width: 1, height: 1 } }), + }, + ], + }, + ]) + expect(registry.elementTypes()).toEqual(new Set(["leaf", "box"])) + expect(registry.containerTypes()).toEqual(new Set(["box"])) + }) + + it("createDefault 产出合法的插件元素", () => { + const registry = createPluginRegistry([qrcodePlugin()]) + const element = registry.getElementPlugin("qrcode")!.createDefault() + expect(element.type).toBe("qrcode") + expect(element.id).toBeTruthy() + expect(element.frame.width).toBe(20) + expect((element.props as { value: string }).value).toBe("https://example.com") + }) +}) + +describe("PluginRegistry 数据源与导出器", () => { + it("注册数据源插件", () => { + const registry = createPluginRegistry([ + { + id: "p-rest", + version: "1.0.0", + datasources: [{ type: "rest", label: "REST" }], + exporters: [{ format: "pdf", label: "PDF" }], + }, + ]) + expect(registry.getDataSourcePlugin("rest")?.type).toBe("rest") + expect(registry.getExporterPlugin("pdf")?.format).toBe("pdf") + }) + + it("collectMissing 报告未注册的插件", () => { + const registry = createPluginRegistry([qrcodePlugin()]) + const missing = registry.collectMissing([ + { pluginId: "qrcode", version: "*", kind: "element" }, + { pluginId: "barcode", version: "*", kind: "element", usedBy: "el-1" }, + { pluginId: "rest", version: "*", kind: "datasource" }, + ]) + expect(missing).toHaveLength(2) + expect(missing.every((d) => d.code === DIAGNOSTIC_CODES.PLUGIN_MISSING)).toBe(true) + expect(missing.find((d) => d.message.includes("barcode"))?.elementId).toBe("el-1") + }) +}) + +describe("PluginRegistry 版本兼容性", () => { + it("声明不兼容版本时产生告警但仍注册", () => { + const registry = new PluginRegistry() + const result = registry.register( + qrcodePlugin({ compatibleSchemaVersion: ">=99.0.0" }), + ) + expect(result.diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.PLUGIN_VERSION_INCOMPATIBLE)).toBe(true) + // 仍然注册,避免阻断打开文档 + expect(registry.has("plugin-qrcode")).toBe(true) + }) + + it("声明兼容版本时不告警", () => { + const registry = new PluginRegistry() + const result = registry.register(qrcodePlugin({ compatibleSchemaVersion: ">=1.0.0" })) + expect(result.diagnostics).toEqual([]) + }) + + it("checkReferences 报告缺失插件", () => { + const registry = createPluginRegistry([qrcodePlugin()]) + const diagnostics = registry.checkReferences([ + { id: "plugin-qrcode", version: "1.2.0" }, + { id: "plugin-barcode", version: "1.0.0" }, + ]) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.code).toBe(DIAGNOSTIC_CODES.PLUGIN_MISSING) + }) + + it("checkReferences 报告版本不匹配", () => { + const registry = createPluginRegistry([qrcodePlugin()]) + const diagnostics = registry.checkReferences([{ id: "plugin-qrcode", version: "^9.0.0" }]) + expect(diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.PLUGIN_VERSION_INCOMPATIBLE)).toBe(true) + }) + + it("checkReferences 接受 undefined", () => { + expect(createPluginRegistry().checkReferences(undefined)).toEqual([]) + }) +}) + +describe("isVersionCompatible", () => { + it("通配符与缺省视为兼容", () => { + expect(isVersionCompatible("1.0.0", "*")).toBe(true) + expect(isVersionCompatible("1.0.0", undefined)).toBe(true) + expect(isVersionCompatible("1.0.0", "")).toBe(true) + }) + + it(">= 语义", () => { + expect(isVersionCompatible("1.5.0", ">=1.2.0")).toBe(true) + expect(isVersionCompatible("1.1.0", ">=1.2.0")).toBe(false) + expect(isVersionCompatible("2.0.0", ">=1.2.0")).toBe(true) + }) + + it("^ 语义按 major 匹配", () => { + expect(isVersionCompatible("1.9.0", "^1.0.0")).toBe(true) + expect(isVersionCompatible("2.0.0", "^1.0.0")).toBe(false) + }) + + it("~ 语义按 major.minor 匹配", () => { + expect(isVersionCompatible("1.2.9", "~1.2.0")).toBe(true) + expect(isVersionCompatible("1.3.0", "~1.2.0")).toBe(false) + }) + + it("无法解析时视为兼容(不阻断)", () => { + expect(isVersionCompatible("1.0.0", "这不是版本号")).toBe(true) + expect(isVersionCompatible("乱码", ">=1.0.0")).toBe(true) + }) + + it("支持 x 通配", () => { + expect(isVersionCompatible("1.5.0", "1.x")).toBe(true) + expect(isVersionCompatible("2.0.0", "1.x")).toBe(false) + }) +}) + +describe("reportRegisterResult", () => { + it("把注册诊断写入收集器", () => { + const bag = new DiagnosticBag() + reportRegisterResult(bag, { + ok: false, + diagnostics: [{ severity: "error", code: "x", message: "boom" }], + }) + expect(bag.size).toBe(1) + expect(bag.hasError).toBe(true) + }) +}) + +describe("插件与文档协作", () => { + it("插件元素能在文档中通过校验", () => { + const registry = createPluginRegistry([qrcodePlugin()]) + const plugin = registry.getElementPlugin("qrcode")! + const doc: ReportDocument = createDocument() + doc.sections[1]!.children = [plugin.createDefault()] + + const types = registry.elementTypes() + expect(types.has("qrcode")).toBe(true) + // 校验时传入插件类型集合,不应报未知类型 + expect(doc.sections[1]?.children[0]?.type).toBe("qrcode") + }) +}) diff --git a/code/one-designer/packages/report-core/tests/serialization.test.ts b/code/one-designer/packages/report-core/tests/serialization.test.ts new file mode 100644 index 00000000..1a6be3e4 --- /dev/null +++ b/code/one-designer/packages/report-core/tests/serialization.test.ts @@ -0,0 +1,258 @@ +/** + * 序列化 / 反序列化 / 迁移测试(对应 §12 与 §14)。 + */ +import { describe, expect, it } from "vitest" +import { + serialize, + serializeValue, + toPlainObject, +} from "../src/serialization/serialize.js" +import { + deserialize, + deserializeLeniently, + deserializeStrictly, + summarizeDiagnostics, + tryDeserialize, +} from "../src/serialization/deserialize.js" +import { migrate, needsMigration } from "../src/serialization/migrate.js" +import { createDocument } from "../src/document/create.js" +import { CURRENT_SCHEMA_VERSION } from "../src/schema/version.js" +import { DIAGNOSTIC_CODES } from "../src/diagnostics/diagnostic.js" +import type { Migration } from "../src/schema/version.js" +import type { TableElement } from "../src/model/table.js" + +describe("serialize", () => { + it("产出稳定排序的 JSON", () => { + const doc = createDocument({ name: "订单" }) + const a = serialize(doc) + const b = serialize(doc) + expect(a).toBe(b) + // 键名按字典序排列:id 在 name 之前 + const parsed = JSON.parse(a) as Record + const keys = Object.keys(parsed) + expect(keys).toEqual([...keys].sort()) + }) + + it("pretty 输出含换行", () => { + const doc = createDocument() + expect(serialize(doc, { pretty: true })).toContain("\n") + expect(serialize(doc)).not.toContain("\n") + }) + + it("序列化前自动带上当前 schemaVersion", () => { + const doc = createDocument() + const parsed = JSON.parse(serialize(doc)) as { schemaVersion: number } + expect(parsed.schemaVersion).toBe(CURRENT_SCHEMA_VERSION) + }) + + it("拒绝含函数的文档(§3.5 纯数据约束)", () => { + const doc = createDocument() + ;(doc as unknown as Record).bad = () => 1 + expect(() => serialize(doc)).toThrow(TypeError) + }) + + it("serializeValue 拒绝函数", () => { + expect(() => serializeValue({ fn: () => 1 })).toThrow(TypeError) + }) + + it("toPlainObject 返回可写盘对象", () => { + const plain = toPlainObject(createDocument({ name: "X" })) + expect(plain.schemaVersion).toBe(CURRENT_SCHEMA_VERSION) + expect(() => JSON.stringify(plain)).not.toThrow() + }) +}) + +describe("deserialize", () => { + it("往返一致", () => { + const doc = createDocument({ name: "往返测试" }) + const result = deserialize(serialize(doc)) + expect(result.ok).toBe(true) + expect(result.document?.name).toBe("往返测试") + expect(result.document?.id).toBe(doc.id) + }) + + it("保留元素内容", () => { + const doc = createDocument() + doc.sections[1]!.children = [ + { id: "e1", type: "text", frame: { x: 1, y: 2, width: 3, height: 4 }, props: { text: "你好" } }, + ] + const result = deserialize(serialize(doc)) + const element = result.document?.sections[1]?.children[0] + expect(element?.id).toBe("e1") + expect((element?.props as { text: string }).text).toBe("你好") + expect(element?.frame).toEqual({ x: 1, y: 2, width: 3, height: 4 }) + }) + + it("JSON 语法错误时返回 ok:false 且不抛异常", () => { + const result = deserialize("{ 不是合法 JSON") + expect(result.ok).toBe(false) + expect(result.document).toBeNull() + expect(result.diagnostics[0]?.code).toBe(DIAGNOSTIC_CODES.SCHEMA_INVALID) + }) + + it("根节点非对象时失败", () => { + const result = deserialize("[]") + expect(result.ok).toBe(false) + expect(result.document).toBeNull() + }) + + it("缺少 schemaVersion 时给出诊断", () => { + const doc = createDocument() + const raw = JSON.parse(serialize(doc)) as Record + delete raw.schemaVersion + const result = deserialize(JSON.stringify(raw)) + expect(result.diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.SCHEMA_VERSION_MISSING)).toBe(true) + }) + + it("未注册的插件元素类型产生告警而非失败", () => { + const doc = createDocument() + doc.sections[1]!.children = [ + { id: "q1", type: "qrcode", frame: { x: 0, y: 0, width: 20, height: 20 } }, + ] + const json = serialize(doc) + const result = deserialize(json, { pluginElementTypes: new Set(["text"]) }) + expect(result.document).not.toBeNull() + expect(result.diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE)).toBe(true) + }) + + it("已注册的插件元素类型不产生告警", () => { + const doc = createDocument() + doc.sections[1]!.children = [ + { id: "q1", type: "qrcode", frame: { x: 0, y: 0, width: 20, height: 20 } }, + ] + const result = deserialize(serialize(doc), { pluginElementTypes: new Set(["qrcode"]) }) + expect( + result.diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE), + ).toBe(false) + }) + + it("引用不存在的数据集给出诊断", () => { + const doc = createDocument() + doc.sections[1]!.children = [ + { + id: "t1", + type: "table", + frame: { x: 0, y: 0, width: 100, height: 30 }, + datasetId: "不存在", + columns: [{ id: "c1", width: 30 }], + } as TableElement, + ] + const result = deserialize(serialize(doc)) + expect(result.diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.REF_DANGLING_DATASET)).toBe(true) + }) + + it("严格模式在结构错误时直接失败", () => { + const result = deserializeStrictly(JSON.stringify({ id: "x" })) + expect(result.ok).toBe(false) + expect(result.document).toBeNull() + }) + + it("宽松模式仍然返回文档", () => { + const result = deserializeLeniently(JSON.stringify({ id: "x", name: "y", page: { paper: "A4" }, sections: [] })) + expect(result.document).not.toBeNull() + }) + + it("tryDeserialize 只返回文档", () => { + const doc = createDocument({ name: "便捷" }) + expect(tryDeserialize(serialize(doc))?.name).toBe("便捷") + expect(tryDeserialize("坏 JSON")).toBeNull() + }) + + it("summarizeDiagnostics 汇总计数", () => { + expect(summarizeDiagnostics([])).toBe("无诊断信息") + const summary = summarizeDiagnostics([ + { severity: "error", code: "a", message: "m" }, + { severity: "warning", code: "b", message: "m" }, + ]) + expect(summary).toContain("错误 1") + expect(summary).toContain("警告 1") + }) + + it("拒绝高于当前版本的文档", () => { + const doc = createDocument() + const raw = JSON.parse(serialize(doc)) as Record + raw.schemaVersion = CURRENT_SCHEMA_VERSION + 5 + const result = deserialize(JSON.stringify(raw)) + expect(result.ok).toBe(false) + expect(result.diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.SCHEMA_VERSION_UNKNOWN)).toBe(true) + }) +}) + +describe("migrate", () => { + it("同版本不产生迁移步骤", () => { + const result = migrate({ schemaVersion: CURRENT_SCHEMA_VERSION }) + expect(result.applied).toEqual([]) + expect(result.fromVersion).toBe(CURRENT_SCHEMA_VERSION) + }) + + it("缺少 schemaVersion 时失败", () => { + const result = migrate({ id: "x" }) + expect(result.diagnostics.some((d) => d.severity === "error")).toBe(true) + }) + + it("按版本逐步执行迁移", () => { + const migrations: Migration[] = [ + { + from: 1, + to: 2, + description: "重命名字段 name → title", + migrate: (doc) => { + const out = { ...doc } + out.title = doc.name + delete out.name + return out + }, + }, + { + from: 2, + to: 3, + description: "补上 units 字段", + migrate: (doc) => ({ ...doc, units: "mm" }), + }, + ] + const result = migrate({ schemaVersion: 1, name: "旧文档" }, 3, migrations) + expect(result.toVersion).toBe(3) + expect(result.applied).toHaveLength(2) + expect(result.document.title).toBe("旧文档") + expect(result.document.units).toBe("mm") + expect(result.document.name).toBeUndefined() + }) + + it("缺失中间迁移时报告失败", () => { + const migrations: Migration[] = [ + { from: 2, to: 3, description: "only", migrate: (doc) => doc }, + ] + const result = migrate({ schemaVersion: 1 }, 3, migrations) + expect(result.diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.SCHEMA_MIGRATION_FAILED)).toBe(true) + }) + + it("禁止跨版本迁移", () => { + const migrations: Migration[] = [ + { from: 1, to: 3, description: "跳版本", migrate: (doc) => doc }, + ] + const result = migrate({ schemaVersion: 1 }, 3, migrations) + expect(result.diagnostics.some((d) => d.code === DIAGNOSTIC_CODES.SCHEMA_MIGRATION_FAILED)).toBe(true) + }) + + it("迁移函数抛异常时不崩溃", () => { + const migrations: Migration[] = [ + { + from: 1, + to: 2, + description: "会爆炸的迁移", + migrate: () => { + throw new Error("boom") + }, + }, + ] + const result = migrate({ schemaVersion: 1 }, 2, migrations) + expect(result.diagnostics.some((d) => d.severity === "error")).toBe(true) + expect(result.toVersion).toBe(1) + }) + + it("needsMigration 判断", () => { + expect(needsMigration({ schemaVersion: 0 })).toBe(false) + expect(needsMigration({ schemaVersion: CURRENT_SCHEMA_VERSION })).toBe(false) + expect(needsMigration({})).toBe(false) + }) +}) diff --git a/code/one-designer/packages/report-core/tests/validation.test.ts b/code/one-designer/packages/report-core/tests/validation.test.ts new file mode 100644 index 00000000..21c52ca7 --- /dev/null +++ b/code/one-designer/packages/report-core/tests/validation.test.ts @@ -0,0 +1,346 @@ +/** + * 结构校验与引用完整性测试(对应 §12、§13、§14)。 + */ +import { describe, expect, it } from "vitest" +import { + detectStyleCycles, + validateDocument, + validateStructure, +} from "../src/schema/validation.js" +import { createDocument } from "../src/document/create.js" +import { DIAGNOSTIC_CODES } from "../src/diagnostics/diagnostic.js" +import { CURRENT_SCHEMA_VERSION } from "../src/schema/version.js" +import type { ReportDocument } from "../src/model/report.js" +import type { StyleDefinition } from "../src/model/style.js" +import type { TableElement } from "../src/model/table.js" + +function baseDocument(): ReportDocument { + return createDocument({ name: "校验测试" }) +} + +describe("validateStructure", () => { + it("合法文档无诊断", () => { + expect(validateStructure(baseDocument())).toEqual([]) + }) + + it("非对象根节点报错", () => { + const d = validateStructure(null) + expect(d[0]?.code).toBe(DIAGNOSTIC_CODES.SCHEMA_INVALID) + }) + + it("缺少必填字段报错并指出路径", () => { + const doc = JSON.parse(JSON.stringify(baseDocument())) as Record + delete doc.name + const d = validateStructure(doc) + const missing = d.find((x) => x.code === DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING) + expect(missing?.path).toBe("/name") + }) + + it("非法 paper 值报错", () => { + const doc = JSON.parse(JSON.stringify(baseDocument())) as Record + ;(doc.page as Record).paper = "B5" + const d = validateStructure(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.SCHEMA_ENUM_INVALID && x.path === "/page/paper")).toBe(true) + }) + + it("自定义纸张必须有正尺寸", () => { + const doc = JSON.parse(JSON.stringify(baseDocument())) as Record + ;(doc.page as Record).paper = "custom" + ;(doc.page as Record).customSize = { width: 0, height: 100 } + const d = validateStructure(doc) + expect(d.some((x) => x.path === "/page/customSize/width")).toBe(true) + }) + + it("元素缺少 id 报错", () => { + const doc = JSON.parse(JSON.stringify(baseDocument())) as Record + ;(doc.sections as Record[])[1]!.children = [ + { type: "text", frame: { x: 0, y: 0, width: 1, height: 1 } }, + ] + const d = validateStructure(doc) + expect(d.some((x) => x.path === "/sections/1/children/0/id")).toBe(true) + }) + + it("元素 frame 非对象报错", () => { + const doc = JSON.parse(JSON.stringify(baseDocument())) as Record + ;(doc.sections as Record[])[1]!.children = [ + { id: "e1", type: "text", frame: "bad" }, + ] + const d = validateStructure(doc) + expect(d.some((x) => x.path === "/sections/1/children/0/frame")).toBe(true) + }) + + it("拒绝不可序列化的值", () => { + const doc = baseDocument() as unknown as Record + doc.fn = () => 1 + const d = validateStructure(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.SCHEMA_INVALID)).toBe(true) + }) + + it("sections 非数组报错", () => { + const doc = JSON.parse(JSON.stringify(baseDocument())) as Record + doc.sections = "bad" + const d = validateStructure(doc) + expect(d.some((x) => x.path === "/sections")).toBe(true) + }) + + it("schemaVersion 非整数报错", () => { + const doc = JSON.parse(JSON.stringify(baseDocument())) as Record + doc.schemaVersion = 1.5 + const d = validateStructure(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.SCHEMA_VERSION_UNKNOWN)).toBe(true) + }) +}) + +describe("validateDocument", () => { + it("合法文档无诊断", () => { + expect(validateDocument(baseDocument())).toEqual([]) + }) + + it("检测重复元素 ID", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { id: "dup", type: "text", frame: { x: 0, y: 0, width: 1, height: 1 } }, + { id: "dup", type: "text", frame: { x: 0, y: 0, width: 1, height: 1 } }, + ] + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.REF_DUPLICATE_ID)).toBe(true) + }) + + it("检测指向不存在样式", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { id: "e1", type: "text", frame: { x: 0, y: 0, width: 1, height: 1 }, styleId: "nope" }, + ] + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.REF_DANGLING_STYLE)).toBe(true) + }) + + it("检测指向不存在的数据集", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { + id: "t1", + type: "table", + frame: { x: 0, y: 0, width: 50, height: 20 }, + datasetId: "nope", + columns: [{ id: "c1", width: 20 }], + } as TableElement, + ] + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.REF_DANGLING_DATASET)).toBe(true) + }) + + it("数据集引用不存在的数据源产生告警", () => { + const doc = baseDocument() + doc.datasets = { ds1: { id: "ds1", sourceId: "nope" } } + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.REF_DANGLING_DATASOURCE)).toBe(true) + }) + + it("未知元素类型在提供插件清单时视为错误", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { id: "x1", type: "unknown-widget", frame: { x: 0, y: 0, width: 1, height: 1 } }, + ] + const d = validateDocument(doc, { pluginElementTypes: new Set(["qrcode"]) }) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE && x.severity === "error")).toBe(true) + }) + + it("未知元素类型在未提供插件清单时降级为告警", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { id: "x1", type: "unknown-widget", frame: { x: 0, y: 0, width: 1, height: 1 } }, + ] + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE && x.severity === "warning")).toBe(true) + }) + + it("已注册的插件元素类型不报错", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { id: "q1", type: "qrcode", frame: { x: 0, y: 0, width: 1, height: 1 } }, + ] + const d = validateDocument(doc, { pluginElementTypes: new Set(["qrcode"]) }) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE)).toBe(false) + }) + + it("叶子元素带 children 报错", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { + id: "t1", + type: "text", + frame: { x: 0, y: 0, width: 1, height: 1 }, + children: [{ id: "c1", type: "text", frame: { x: 0, y: 0, width: 1, height: 1 } }], + }, + ] + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.ELEMENT_CHILD_NOT_ALLOWED)).toBe(true) + }) + + it("负宽高报错", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { id: "e1", type: "text", frame: { x: 0, y: 0, width: -5, height: 1 } }, + ] + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.ELEMENT_FRAME_INVALID)).toBe(true) + }) + + it("明细表列宽必须为正", () => { + const doc = baseDocument() + doc.datasets = { ds1: { id: "ds1" } } + doc.sections[1]!.children = [ + { + id: "t1", + type: "table", + frame: { x: 0, y: 0, width: 50, height: 20 }, + datasetId: "ds1", + columns: [{ id: "c1", width: 0 }], + } as TableElement, + ] + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.SCHEMA_RANGE_INVALID)).toBe(true) + }) + + it("明细表缺少 datasetId 报错", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { + id: "t1", + type: "table", + frame: { x: 0, y: 0, width: 50, height: 20 }, + columns: [{ id: "c1", width: 20 }], + } as never, + ] + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING)).toBe(true) + }) + + it("非法参数名报错", () => { + const doc = baseDocument() + doc.parameters = { p1: { id: "p1", name: "非法 名称" } } + const d = validateDocument(doc) + expect(d.some((x) => x.path === "/parameters/p1/name")).toBe(true) + }) + + it("重复参数名报错", () => { + const doc = baseDocument() + doc.parameters = { + p1: { id: "p1", name: "same" }, + p2: { id: "p2", name: "same" }, + } + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.REF_DUPLICATE_ID)).toBe(true) + }) + + it("保留区域产生提示性告警", () => { + const doc = baseDocument() + doc.sections.push({ id: "s9", kind: "summary", children: [] }) + const d = validateDocument(doc) + expect(d.some((x) => x.path === "/sections/3/kind")).toBe(true) + }) + + it("嵌套元素也会被校验", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { + id: "ct1", + type: "container", + frame: { x: 0, y: 0, width: 50, height: 50 }, + children: [ + { id: "bad", type: "nope-plugin", frame: { x: 0, y: 0, width: 1, height: 1 } }, + ], + }, + ] + const d = validateDocument(doc, { pluginElementTypes: new Set() }) + expect( + d.some( + (x) => + x.code === DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE && + x.path === "/sections/1/children/0/children/0/type", + ), + ).toBe(true) + }) + + it("表格单元格内元素也会被校验", () => { + const doc = baseDocument() + doc.datasets = { ds1: { id: "ds1" } } + doc.sections[1]!.children = [ + { + id: "t1", + type: "table", + frame: { x: 0, y: 0, width: 50, height: 20 }, + datasetId: "ds1", + columns: [ + { + id: "c1", + width: 20, + children: [ + { id: "bad", type: "nope-plugin", frame: { x: 0, y: 0, width: 1, height: 1 } }, + ], + }, + ], + } as TableElement, + ] + const d = validateDocument(doc, { pluginElementTypes: new Set() }) + expect(d.some((x) => x.elementId === "bad")).toBe(true) + }) + + it("诊断指向具体元素 ID", () => { + const doc = baseDocument() + doc.sections[1]!.children = [ + { id: "el-42", type: "nope", frame: { x: 0, y: 0, width: 1, height: 1 } }, + ] + const d = validateDocument(doc, { pluginElementTypes: new Set() }) + const target = d.find((x) => x.code === DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE) + expect(target?.elementId).toBe("el-42") + }) +}) + +describe("detectStyleCycles", () => { + it("无循环时返回空", () => { + const styles: Record = { + a: { id: "a" }, + b: { id: "b", extends: "a" }, + } + expect(detectStyleCycles(styles)).toEqual([]) + }) + + it("检测直接自引用", () => { + const cycles = detectStyleCycles({ a: { id: "a", extends: "a" } }) + expect(cycles.length).toBeGreaterThan(0) + }) + + it("检测多级循环", () => { + const cycles = detectStyleCycles({ + a: { id: "a", extends: "b" }, + b: { id: "b", extends: "a" }, + }) + expect(cycles.length).toBeGreaterThan(0) + }) + + it("循环继承在校验中报错", () => { + const doc = baseDocument() + doc.styles = { + a: { id: "a", extends: "b" }, + b: { id: "b", extends: "a" }, + } + const d = validateDocument(doc) + expect(d.some((x) => x.code === DIAGNOSTIC_CODES.REF_CYCLE_DETECTED)).toBe(true) + }) +}) + +describe("schemaVersion 边界", () => { + it("当前版本被接受", () => { + const doc = baseDocument() + doc.schemaVersion = CURRENT_SCHEMA_VERSION + expect(validateStructure(doc)).toEqual([]) + }) + + it("版本 0 被拒绝", () => { + const doc = JSON.parse(JSON.stringify(baseDocument())) as Record + doc.schemaVersion = 0 + expect(validateStructure(doc).some((x) => x.code === DIAGNOSTIC_CODES.SCHEMA_VERSION_UNKNOWN)).toBe(true) + }) +}) diff --git a/code/one-designer/packages/report-core/tsconfig.json b/code/one-designer/packages/report-core/tsconfig.json new file mode 100644 index 00000000..ae429f46 --- /dev/null +++ b/code/one-designer/packages/report-core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/code/one-designer/packages/report-core/tsconfig.test.json b/code/one-designer/packages/report-core/tsconfig.test.json new file mode 100644 index 00000000..6cefc694 --- /dev/null +++ b/code/one-designer/packages/report-core/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "*.config.ts"] +} diff --git a/code/one-designer/packages/report-core/tsup.config.ts b/code/one-designer/packages/report-core/tsup.config.ts new file mode 100644 index 00000000..be47bf93 --- /dev/null +++ b/code/one-designer/packages/report-core/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + target: "es2022", +}) diff --git a/code/one-designer/packages/report-core/vitest.config.ts b/code/one-designer/packages/report-core/vitest.config.ts new file mode 100644 index 00000000..0af27743 --- /dev/null +++ b/code/one-designer/packages/report-core/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + }, +}) diff --git a/code/one-designer/packages/report-designer-vue/package.json b/code/one-designer/packages/report-designer-vue/package.json new file mode 100644 index 00000000..02753eab --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/package.json @@ -0,0 +1,43 @@ +{ + "name": "@report/designer-vue", + "version": "0.1.0", + "description": "报表设计器的 Vue 组件层(画布、工具栏、面板、状态栏),Headless 思路,宿主可覆写主题", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./style.css": "./dist/style.css" + }, + "files": ["dist"], + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "vue-tsc --noEmit -p tsconfig.test.json" + }, + "dependencies": { + "@report/core": "workspace:*", + "@report/layout": "workspace:*", + "@report/designer": "workspace:*" + }, + "peerDependencies": { + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", + "jsdom": "^25.0.1", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vitest": "^2.1.8", + "vue": "^3.5.13", + "vue-tsc": "^2.1.10" + } +} diff --git a/code/one-designer/packages/report-designer-vue/src/ReportDesigner.vue b/code/one-designer/packages/report-designer-vue/src/ReportDesigner.vue new file mode 100644 index 00000000..964d7211 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/ReportDesigner.vue @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + diff --git a/code/one-designer/packages/report-designer-vue/src/components/ReportCanvas.vue b/code/one-designer/packages/report-designer-vue/src/components/ReportCanvas.vue new file mode 100644 index 00000000..ed35c081 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/components/ReportCanvas.vue @@ -0,0 +1,317 @@ + + + + + mm + + + + + + + + + {{ elementText(element) }} + + + + + + + + diff --git a/code/one-designer/packages/report-designer-vue/src/components/ReportProperties.vue b/code/one-designer/packages/report-designer-vue/src/components/ReportProperties.vue new file mode 100644 index 00000000..3d131ad5 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/components/ReportProperties.vue @@ -0,0 +1,135 @@ + + + + + + + diff --git a/code/one-designer/packages/report-designer-vue/src/components/ReportRuler.vue b/code/one-designer/packages/report-designer-vue/src/components/ReportRuler.vue new file mode 100644 index 00000000..8ce10e50 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/components/ReportRuler.vue @@ -0,0 +1,141 @@ + + + + + + + {{ tick.label }} + + + + + + diff --git a/code/one-designer/packages/report-designer-vue/src/components/ReportStatusbar.vue b/code/one-designer/packages/report-designer-vue/src/components/ReportStatusbar.vue new file mode 100644 index 00000000..79f77502 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/components/ReportStatusbar.vue @@ -0,0 +1,42 @@ + + + + + 缩放 {{ zoomPercent }} + 单位 mm + 元素 {{ elementCount }} + + + + diff --git a/code/one-designer/packages/report-designer-vue/src/components/ReportToolbar.vue b/code/one-designer/packages/report-designer-vue/src/components/ReportToolbar.vue new file mode 100644 index 00000000..34669d29 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/components/ReportToolbar.vue @@ -0,0 +1,137 @@ + + + + + + + 撤销 + + + 重做 + + + + + − + {{ zoomPercent }} + + + + + + 预览 + + + + + diff --git a/code/one-designer/packages/report-designer-vue/src/components/ReportToolbox.vue b/code/one-designer/packages/report-designer-vue/src/components/ReportToolbox.vue new file mode 100644 index 00000000..7301e110 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/components/ReportToolbox.vue @@ -0,0 +1,71 @@ + + + + + + + diff --git a/code/one-designer/packages/report-designer-vue/src/index.ts b/code/one-designer/packages/report-designer-vue/src/index.ts new file mode 100644 index 00000000..7cd21b09 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/index.ts @@ -0,0 +1,27 @@ +/** + * @report/designer-vue 公共入口(对应设计文档 §4.6、§14)。 + * + * 只负责 Vue 组件、面板和样式实现,不向 Core 泄漏 Vue 类型。 + * 组件采用 Headless 思路,宿主可覆写主题(通过 CSS 变量)。 + */ +import ReportDesigner from "./ReportDesigner.vue" +import ReportToolbar from "./components/ReportToolbar.vue" +import ReportToolbox from "./components/ReportToolbox.vue" +import ReportCanvas from "./components/ReportCanvas.vue" +import ReportRuler from "./components/ReportRuler.vue" +import ReportProperties from "./components/ReportProperties.vue" +import ReportStatusbar from "./components/ReportStatusbar.vue" + +export { + ReportDesigner, + ReportToolbar, + ReportToolbox, + ReportCanvas, + ReportRuler, + ReportProperties, + ReportStatusbar, +} + +export { themeCss, injectThemeStyle } from "./theme.js" +export { useEditor } from "./use-editor.js" +export { useShortcuts } from "./use-shortcuts.js" diff --git a/code/one-designer/packages/report-designer-vue/src/ruler.ts b/code/one-designer/packages/report-designer-vue/src/ruler.ts new file mode 100644 index 00000000..94f90c41 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/ruler.ts @@ -0,0 +1,88 @@ +/** + * 标尺刻度计算(对应设计文档 §14.7 画布规范)。 + * + * 抽成纯函数的原因:步长选择、标签密度这类逻辑很容易在缩放时出边界问题, + * 放在组件里只能靠渲染结果间接验证,单独拿出来可以直接断言。 + * + * 坐标约定:刻度位置 `px` 是**距页面原点的显示像素**,已含缩放; + * `mm` 是距页面原点的毫米值(即文档坐标)。 + */ +import { mmToPx } from "@report/layout" + +/** 主刻度候选步长(mm)。 */ +const TICK_CANDIDATES_MM = [1, 2, 5, 10, 20, 50] as const + +/** 标签候选步长(mm),需比主刻度更稀疏。 */ +const LABEL_CANDIDATES_MM = [1, 2, 5, 10, 20, 50, 100] as const + +/** 主刻度最小视觉间距(px),低于此值刻度会糊成一片。 */ +const MIN_TICK_SPACING_PX = 8 + +/** 标签最小视觉间距(px),低于此值数字会重叠。 */ +const MIN_LABEL_SPACING_PX = 40 + +/** 刻度数量上限,防止自定义纸张尺寸过大时生成海量 DOM。 */ +const MAX_TICKS = 1000 + +/** 一根刻度。 */ +export interface RulerTick { + /** 距页面原点的毫米值。 */ + mm: number + /** 距页面原点的显示像素(已含缩放)。 */ + px: number + /** 需要显示的数字标签;次刻度为 undefined。 */ + label?: string +} + +/** 从候选步长里取第一个在给定缩放下间距达标的。 */ +function firstFitting( + candidates: readonly number[], + zoom: number, + minSpacingPx: number, + atLeastMm = 0, +): number { + for (const candidate of candidates) { + if (candidate < atLeastMm) continue + if (mmToPx(candidate) * zoom >= minSpacingPx) return candidate + } + return candidates[candidates.length - 1]! +} + +/** 按缩放选择主刻度步长(mm)。 */ +export function chooseRulerStepMm(zoom: number): number { + return firstFitting(TICK_CANDIDATES_MM, zoom, MIN_TICK_SPACING_PX) +} + +/** + * 生成标尺刻度。 + * + * @param lengthMm 标尺覆盖的长度(mm),通常是纸张宽/高。 + * @param zoom 当前缩放比例。 + */ +export function buildRulerTicks(lengthMm: number, zoom: number): RulerTick[] { + if (!(lengthMm > 0) || !(zoom > 0)) return [] + + const stepMm = chooseRulerStepMm(zoom) + // 标签步长与主刻度步长都取自同一套候选值,因此 labelEvery 一定是正整数。 + const labelStepMm = firstFitting( + LABEL_CANDIDATES_MM, + zoom, + MIN_LABEL_SPACING_PX, + stepMm, + ) + const labelEvery = Math.max(1, Math.round(labelStepMm / stepMm)) + + const ticks: RulerTick[] = [] + // 加 1e-9 防止 210 / 5 这类整除因浮点误差少算一根。 + const count = Math.min(MAX_TICKS - 1, Math.floor(lengthMm / stepMm + 1e-9)) + for (let i = 0; i <= count; i += 1) { + const mm = i * stepMm + const px = mmToPx(mm) * zoom + if (i % labelEvery === 0) { + ticks.push({ mm, px, label: String(Math.round(mm)) }) + } else { + ticks.push({ mm, px }) + } + } + return ticks +} diff --git a/code/one-designer/packages/report-designer-vue/src/theme.ts b/code/one-designer/packages/report-designer-vue/src/theme.ts new file mode 100644 index 00000000..bc06c6f9 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/theme.ts @@ -0,0 +1,68 @@ +/** + * 设计令牌(对应设计文档 §14.4)。 + * + * 使用 CSS 自定义属性作为主题契约,提供亮色与暗色两套主题。 + * 宿主应用可通过覆写这些变量来定制品牌色,但必须保持语义: + * background / canvas / foreground / muted / border / primary / + * destructive / selection。 + * + * 颜色使用 HSL 空间(h s% l% 三元组),便于组件里用 hsl(var(--x)) 拼装。 + */ +export const themeCss = /* css */ ` +:root { + --rd-background: 0 0% 100%; + --rd-foreground: 222 47% 11%; + --rd-muted: 210 40% 96%; + --rd-muted-foreground: 215 16% 47%; + --rd-border: 214 32% 91%; + --rd-input: 214 32% 91%; + --rd-primary: 221 83% 53%; + --rd-primary-foreground: 210 40% 98%; + --rd-destructive: 0 72% 51%; + --rd-canvas: 220 14% 96%; + --rd-selection: 221 83% 53%; + --rd-radius: 8px; + + /* 纸张与墨色:属于文档表面,不随亮/暗主题切换(§14.7)。 */ + --rd-paper: 0 0% 100%; + --rd-ink: 222 47% 11%; +} + +.dark { + --rd-background: 222 47% 11%; + --rd-foreground: 210 40% 98%; + --rd-muted: 217 33% 17%; + --rd-muted-foreground: 215 20% 65%; + --rd-border: 217 33% 17%; + --rd-input: 217 33% 17%; + --rd-primary: 217 91% 60%; + --rd-primary-foreground: 222 47% 11%; + --rd-destructive: 0 63% 31%; + --rd-canvas: 222 47% 8%; + --rd-selection: 217 91% 60%; +} + +.rd-designer { + color: hsl(var(--rd-foreground)); + background: hsl(var(--rd-background)); + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 13px; + line-height: 1.4; +} + +.rd-designer * { + box-sizing: border-box; +} +` + +/** 注入主题样式到文档 head(幂等)。 */ +export function injectThemeStyle(): void { + if (typeof document === "undefined") return + const id = "rd-designer-theme" + if (document.getElementById(id)) return + const style = document.createElement("style") + style.id = id + style.textContent = themeCss + document.head.appendChild(style) +} diff --git a/code/one-designer/packages/report-designer-vue/src/use-editor.ts b/code/one-designer/packages/report-designer-vue/src/use-editor.ts new file mode 100644 index 00000000..55c8d92e --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/use-editor.ts @@ -0,0 +1,185 @@ +/** + * 编辑器响应式绑定(composable)。 + * + * 把 designer 包的 EditorSession(非响应式类)包装成 Vue 响应式状态, + * 供组件消费。文档与状态变化时触发重渲染。 + * + * 这里只暴露组件真正用到的能力。designer 包里还有 pan / hoveredId 等 + * 状态,但当前画布用滚动容器承载平移、也没有 hover 高亮,故暂不暴露, + * 避免出现「有 API 没人用」的死代码。 + */ +import { reactive, readonly, computed } from "vue" +import type { ReportDocument, ReportElement, Rect } from "@report/core" +import type { EditorSession } from "@report/designer" + +/** 把 EditorSession 包装成响应式视图。 */ +export function useEditor(editor: EditorSession) { + // 用一个自增版本号驱动重渲染:EditorSession 内部是不可变替换, + // 没有 Vue 的响应式钩子,这里在每次操作后手动 bump。 + const state = reactive({ + version: 0, + document: editor.getDocument() as ReportDocument, + selectedIds: [] as string[], + zoom: editor.getState().zoom, + /** 是否正在拖拽(用于切换光标)。 */ + dragging: false, + }) + + /** 同步编辑器最新状态到响应式视图。 */ + function sync(): void { + state.version += 1 + state.document = editor.getDocument() + state.selectedIds = editor.getSelectedIds() + state.zoom = editor.getState().zoom + state.dragging = editor.isDragging() + } + + // ------------------------------------------------------------------------- + // 选择 + // ------------------------------------------------------------------------- + + /** 选中单个元素。 */ + function select(id: string): void { + editor.select(id) + sync() + } + + /** 多选。 */ + function selectMany(ids: string[]): void { + editor.selectMany(ids) + sync() + } + + /** 切换单个元素的选中态(Ctrl/Cmd + 点击)。 */ + function toggleSelect(id: string): void { + editor.toggleSelect(id) + sync() + } + + /** 清空选中。 */ + function clearSelection(): void { + editor.clearSelection() + sync() + } + + // ------------------------------------------------------------------------- + // 编辑操作 + // ------------------------------------------------------------------------- + + /** 移动元素(frame 为文档绝对坐标 mm)。 */ + function move(frames: Record): void { + editor.moveElements(frames) + sync() + } + + /** 删除当前选中的元素。 */ + function deleteSelected(): void { + editor.deleteSelected() + sync() + } + + /** 复制当前选中元素到剪贴板。 */ + function copy(): void { + editor.copy() + } + + /** 粘贴剪贴板元素到指定区域。 */ + function paste(sectionId: string): void { + editor.paste(sectionId) + sync() + } + + // ------------------------------------------------------------------------- + // 拖拽 + // ------------------------------------------------------------------------- + + /** + * 开始拖拽。 + * + * startX / startY 为指针对应的文档坐标(mm),仅用于记录起点。 + */ + function beginDrag(startX: number, startY: number): void { + editor.beginDrag(startX, startY) + sync() + } + + /** + * 拖拽更新。 + * + * dx / dy 必须是**文档坐标(mm)**位移量——调用方负责把屏幕像素 + * 除以缩放比例再换算成 mm。吸附由 EditorSession 内部处理。 + */ + function updateDrag(dx: number, dy: number): void { + editor.updateDrag(dx, dy) + sync() + } + + /** 结束拖拽。 */ + function endDrag(): void { + editor.endDrag() + sync() + } + + /** 是否正在拖拽。 */ + function isDragging(): boolean { + return editor.isDragging() + } + + // ------------------------------------------------------------------------- + // 撤销 / 重做 / 视图 + // ------------------------------------------------------------------------- + + /** 撤销。 */ + function undo(): void { + editor.undo() + sync() + } + + /** 重做。 */ + function redo(): void { + editor.redo() + sync() + } + + /** 缩放。 */ + function setZoom(zoom: number): void { + editor.setZoom(zoom) + sync() + } + + /** 收集文档所有顶层元素(供画布渲染)。 */ + function elements(): ReportElement[] { + const doc = state.document + const detail = doc.sections.find((s) => s.kind === "detail") + if (!detail) return [] + return detail.children + } + + return { + state: readonly(state), + editor, + sync, + select, + selectMany, + toggleSelect, + clearSelection, + move, + deleteSelected, + copy, + paste, + beginDrag, + updateDrag, + endDrag, + isDragging, + undo, + redo, + setZoom, + elements, + // canUndo/canRedo 必须依赖响应式的 version 计数器: + // EditorSession 是普通类,其内部变化 Vue 无法追踪, + // 若只调用 editor.canUndo(),computed 将永远停留在初始值 false, + // 撤销/重做按钮永远禁用。读一下 state.version 让 sync() 后重新求值。 + canUndo: computed(() => (state.version >= 0, editor.canUndo())), + canRedo: computed(() => (state.version >= 0, editor.canRedo())), + } +} diff --git a/code/one-designer/packages/report-designer-vue/src/use-shortcuts.ts b/code/one-designer/packages/report-designer-vue/src/use-shortcuts.ts new file mode 100644 index 00000000..52dbfb39 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/src/use-shortcuts.ts @@ -0,0 +1,69 @@ +/** + * 编辑器快捷键(对应设计文档 §14.9)。 + * + * 监听挂在 window 上:画布本身不可聚焦,挂在组件根上会因焦点不在其中而失效。 + * + * 关键约束:属性面板里有文本输入框,若不过滤输入态,用户在输入框里按 + * Delete / Backspace 会连带删除画布上选中的元素。 + */ +import { onBeforeUnmount, onMounted } from "vue" +import { useEditor } from "./use-editor.js" + +/** 事件目标是否处于文本编辑态。 */ +function isEditingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + const tag = target.tagName + return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable +} + +/** 注册编辑器快捷键,组件卸载时自动解绑。 */ +export function useShortcuts(editor: ReturnType): void { + function onKeyDown(event: KeyboardEvent): void { + if (isEditingTarget(event.target)) return + + const mod = event.ctrlKey || event.metaKey + const key = event.key.toLowerCase() + + if (mod && key === "z") { + event.preventDefault() + // Ctrl+Shift+Z 是重做,与 Ctrl+Z 对称。 + if (event.shiftKey) editor.redo() + else editor.undo() + return + } + + // Ctrl+Y:Windows 习惯的重做键。 + if (mod && key === "y") { + event.preventDefault() + editor.redo() + return + } + + if (mod && key === "c") { + event.preventDefault() + editor.copy() + return + } + + if (mod && key === "v") { + event.preventDefault() + // 粘贴目标固定为明细区,与工具箱新增元素的口径一致。 + const detail = editor.state.document.sections.find((s) => s.kind === "detail") + if (detail) editor.paste(detail.id) + return + } + + if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault() + editor.deleteSelected() + return + } + + if (event.key === "Escape") { + editor.clearSelection() + } + } + + onMounted(() => window.addEventListener("keydown", onKeyDown)) + onBeforeUnmount(() => window.removeEventListener("keydown", onKeyDown)) +} diff --git a/code/one-designer/packages/report-designer-vue/tests/designer-vue.test.ts b/code/one-designer/packages/report-designer-vue/tests/designer-vue.test.ts new file mode 100644 index 00000000..f0dcbad5 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/tests/designer-vue.test.ts @@ -0,0 +1,404 @@ +/** + * report-designer-vue 组件测试。 + * + * 验证: + * - 主布局挂载后渲染三栏结构(工具栏/工具箱/属性面板/状态栏/画布)。 + * - 工具箱可新增元素并反映到画布。 + * - 点击画布元素可选中,属性面板显示其信息;点击空白取消选中。 + * - 工具栏撤销可用。 + * - 画布缩放、页面尺寸、标尺刻度。 + * - 拖拽移动元素,且连续拖拽合并为一步撤销。 + * - 快捷键(Delete / Ctrl+Z),输入态下不触发。 + * - 主题样式注入是幂等的。 + */ +import { describe, expect, it, beforeEach } from "vitest" +import { mount, flushPromises } from "@vue/test-utils" +import { createDocument, type ReportDocument } from "@report/core" +import { mmToPx } from "@report/layout" +import ReportDesigner from "../src/ReportDesigner.vue" +import ReportToolbar from "../src/components/ReportToolbar.vue" +import ReportToolbox from "../src/components/ReportToolbox.vue" +import ReportCanvas from "../src/components/ReportCanvas.vue" +import ReportProperties from "../src/components/ReportProperties.vue" +import ReportStatusbar from "../src/components/ReportStatusbar.vue" +import { useEditor } from "../src/use-editor.js" +import { injectThemeStyle } from "../src/theme.js" +import { buildRulerTicks, chooseRulerStepMm } from "../src/ruler.js" +import { createEditorSession } from "@report/designer" + +function sampleDoc(): ReportDocument { + const doc = createDocument({ name: "UI 测试" }) + const detail = doc.sections.find((s) => s.kind === "detail")! + detail.children.push({ + id: "t1", + type: "text", + frame: { x: 0, y: 0, width: 50, height: 8 }, + props: { text: "标题" }, + }) + return doc +} + +/** 取画布页面的实际宽度(px),从内联样式解析。 */ +function pageWidthPx(wrapper: ReturnType): number { + const style = wrapper.find(".rd-canvas-page").attributes("style") ?? "" + const match = /width:\s*([\d.]+)px/.exec(style) + return match ? Number(match[1]) : Number.NaN +} + +/** 取画布 sizer 的宽度(px)。 */ +function sizerWidthPx(wrapper: ReturnType): number { + const style = wrapper.find(".rd-canvas-sizer").attributes("style") ?? "" + const match = /width:\s*([\d.]+)px/.exec(style) + return match ? Number(match[1]) : Number.NaN +} + +/** 在工具栏里按 aria-label 找按钮。 */ +function toolbarButton(wrapper: ReturnType, label: string) { + return wrapper + .findComponent(ReportToolbar) + .findAll("button") + .find((b) => b.attributes("aria-label") === label)! +} + +describe("ReportDesigner 主布局", () => { + it("挂载后渲染三栏结构与五个区域", () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + expect(wrapper.findComponent(ReportToolbar).exists()).toBe(true) + expect(wrapper.findComponent(ReportToolbox).exists()).toBe(true) + expect(wrapper.findComponent(ReportCanvas).exists()).toBe(true) + expect(wrapper.findComponent(ReportProperties).exists()).toBe(true) + expect(wrapper.findComponent(ReportStatusbar).exists()).toBe(true) + }) + + it("画布渲染文档中的元素", () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + const canvas = wrapper.findComponent(ReportCanvas) + expect(canvas.findAll(".rd-canvas-element").length).toBe(1) + expect(canvas.text()).toContain("标题") + }) + + it("点击画布元素后属性面板显示信息", async () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + const element = wrapper.find(".rd-canvas-element") + await element.trigger("mousedown") + await flushPromises() + + const properties = wrapper.findComponent(ReportProperties) + expect(properties.text()).toContain("t1") + expect(properties.text()).toContain("text") + + // mousedown 会挂上全局拖拽监听,测试结束必须卸载以解绑。 + wrapper.unmount() + }) + + it("属性面板可编辑文本内容", async () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + await wrapper.find(".rd-canvas-element").trigger("mousedown") + await flushPromises() + + const input = wrapper.find(".rd-prop-input") + expect(input.exists()).toBe(true) + await input.setValue("改后的标题") + await flushPromises() + + // 画布同步显示新文本 + expect(wrapper.findComponent(ReportCanvas).text()).toContain("改后的标题") + + wrapper.unmount() + }) + + it("工具箱新增文本元素并出现在画布", async () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + const before = wrapper.findAll(".rd-canvas-element").length + + const toolbox = wrapper.findComponent(ReportToolbox) + await toolbox.find("button").trigger("click") + await flushPromises() + + const after = wrapper.findAll(".rd-canvas-element").length + expect(after).toBe(before + 1) + }) + + it("工具栏撤销新增操作", async () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + const before = wrapper.findAll(".rd-canvas-element").length + + await wrapper.findComponent(ReportToolbox).find("button").trigger("click") + await flushPromises() + expect(wrapper.findAll(".rd-canvas-element").length).toBe(before + 1) + + const toolbar = wrapper.findComponent(ReportToolbar) + const undoBtn = toolbar.findAll("button").find((b) => b.attributes("aria-label") === "撤销")! + await undoBtn.trigger("click") + await flushPromises() + + expect(wrapper.findAll(".rd-canvas-element").length).toBe(before) + }) + + it("无选中时属性面板显示空态", () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + const properties = wrapper.findComponent(ReportProperties) + expect(properties.text()).toContain("未选中元素") + }) +}) + +describe("useEditor composable", () => { + it("select 与 move 反映到状态", () => { + const doc = sampleDoc() + const session = createEditorSession(doc) + const editor = useEditor(session) + + editor.select("t1") + expect(editor.state.selectedIds).toContain("t1") + + editor.move({ t1: { x: 20, y: 30, width: 50, height: 8 } }) + const moved = editor.state.document.sections + .find((s) => s.kind === "detail")!.children[0]!.frame + expect(moved.x).toBe(20) + expect(moved.y).toBe(30) + }) + + it("undo 后文档恢复", () => { + const doc = sampleDoc() + const session = createEditorSession(doc) + const editor = useEditor(session) + + editor.move({ t1: { x: 99, y: 99, width: 50, height: 8 } }) + editor.undo() + const restored = editor.state.document.sections + .find((s) => s.kind === "detail")!.children[0]!.frame + expect(restored.x).toBe(0) + }) +}) + +describe("主题注入", () => { + beforeEach(() => { + document.head.innerHTML = "" + }) + + it("注入主题样式到 head", () => { + injectThemeStyle() + const style = document.getElementById("rd-designer-theme") + expect(style).not.toBeNull() + expect(style!.textContent).toContain("--rd-background") + expect(style!.textContent).toContain(".dark") + }) + + it("重复注入是幂等的", () => { + injectThemeStyle() + injectThemeStyle() + const styles = document.querySelectorAll("#rd-designer-theme") + expect(styles.length).toBe(1) + }) +}) + +describe("标尺刻度计算", () => { + it("步长随缩放自适应,保证刻度不会挤在一起", () => { + // mmToPx(1)=3.78px、2=7.56px、5=18.9px,阈值 8px + expect(chooseRulerStepMm(1)).toBe(5) + expect(chooseRulerStepMm(2)).toBe(2) + expect(chooseRulerStepMm(0.5)).toBe(5) + }) + + it("刻度从页面原点开始且不超出纸张长度", () => { + const ticks = buildRulerTicks(210, 1) + expect(ticks.length).toBeGreaterThan(0) + expect(ticks[0]!.mm).toBe(0) + expect(ticks[0]!.px).toBe(0) + + const last = ticks[ticks.length - 1]! + expect(last.mm).toBeLessThanOrEqual(210) + // px 必须是 mm 换算后乘缩放的结果 + expect(last.px).toBeCloseTo(mmToPx(last.mm) * 1, 6) + }) + + it("标签间距不小于 40px,避免数字重叠", () => { + const labeled = buildRulerTicks(210, 1).filter((t) => t.label !== undefined) + expect(labeled.length).toBeGreaterThan(1) + for (let i = 1; i < labeled.length; i += 1) { + expect(labeled[i]!.px - labeled[i - 1]!.px).toBeGreaterThanOrEqual(40) + } + }) + + it("放大后刻度更密", () => { + expect(buildRulerTicks(210, 2).length).toBeGreaterThan( + buildRulerTicks(210, 1).length, + ) + }) + + it("非法输入返回空数组", () => { + expect(buildRulerTicks(0, 1)).toEqual([]) + expect(buildRulerTicks(210, 0)).toEqual([]) + }) +}) + +describe("标尺渲染", () => { + it("画布渲染水平与垂直标尺及刻度", () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + expect(wrapper.find(".rd-canvas-ruler-h").exists()).toBe(true) + expect(wrapper.find(".rd-canvas-ruler-v").exists()).toBe(true) + expect(wrapper.findAll(".rd-ruler-tick").length).toBeGreaterThan(0) + expect(wrapper.findAll(".rd-ruler-label").length).toBeGreaterThan(0) + }) + + it("标尺随滚动同步偏移", async () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + const viewport = wrapper.find(".rd-canvas-viewport") + // jsdom 没有真实布局,直接造一个滚动量再派发 scroll。 + Object.defineProperty(viewport.element, "scrollLeft", { + value: 40, + configurable: true, + }) + await viewport.trigger("scroll") + + // 轨道偏移 = 纸张起点(24) − 滚动量(40) = −16 + const track = wrapper.find(".rd-canvas-ruler-h .rd-ruler-track") + expect(track.attributes("style")).toContain("left: -16px") + }) +}) + +describe("画布页面尺寸与缩放", () => { + it("页面尺寸取自文档页面设置(A4 竖版)", () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + expect(pageWidthPx(wrapper)).toBeCloseTo(mmToPx(210), 3) + }) + + it("换成 A5 后页面尺寸随之变化", () => { + const doc = sampleDoc() + doc.page.paper = "A5" + const wrapper = mount(ReportDesigner, { props: { document: doc } }) + expect(pageWidthPx(wrapper)).toBeCloseTo(mmToPx(148), 3) + }) + + it("缩放真实作用于页面(transform),并撑开滚动区域", async () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + expect(wrapper.find(".rd-canvas-page").attributes("style")).toContain("scale(1)") + const beforeSizer = sizerWidthPx(wrapper) + + await toolbarButton(wrapper, "放大").trigger("click") + await flushPromises() + + // 回归:旧实现只改状态栏数字,页面 transform 不变 + expect(wrapper.find(".rd-canvas-page").attributes("style")).toContain("scale(1.25)") + expect(sizerWidthPx(wrapper)).toBeGreaterThan(beforeSizer) + }) +}) + +describe("画布拖拽", () => { + /** 直接挂 ReportCanvas,便于从 session 读取拖拽结果。 */ + function mountCanvas() { + const doc = sampleDoc() + const session = createEditorSession(doc) + const editor = useEditor(session) + const wrapper = mount(ReportCanvas, { props: { editor } }) + return { wrapper, session, editor } + } + + it("拖拽改变元素 frame,结束后退出拖拽态", async () => { + const { wrapper, session, editor } = mountCanvas() + + await wrapper.find(".rd-canvas-element").trigger("mousedown", { + clientX: 100, + clientY: 100, + }) + expect(editor.isDragging()).toBe(true) + + // 屏幕位移 96px = 25.4mm,吸附到整数 mm → 25 + document.dispatchEvent(new MouseEvent("mousemove", { clientX: 196, clientY: 100 })) + await flushPromises() + + const frame = session + .getDocument() + .sections.find((s) => s.kind === "detail")!.children[0]!.frame + expect(frame.x).toBe(25) + expect(frame.y).toBe(0) + + document.dispatchEvent(new MouseEvent("mouseup")) + await flushPromises() + expect(editor.isDragging()).toBe(false) + + wrapper.unmount() + }) + + it("一次拖拽的多次位移合并为一步撤销", async () => { + const { wrapper, session } = mountCanvas() + const detail = () => session.getDocument().sections.find((s) => s.kind === "detail")! + + await wrapper.find(".rd-canvas-element").trigger("mousedown", { + clientX: 100, + clientY: 100, + }) + // 模拟拖拽过程中的多次 mousemove + for (const x of [130, 160, 196]) { + document.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: 100 })) + } + document.dispatchEvent(new MouseEvent("mouseup")) + await flushPromises() + + expect(detail().children[0]!.frame.x).toBe(25) + + // 依赖 moveElementsCommand 的 mergeKey,一次撤销即回到原位 + session.undo() + expect(detail().children[0]!.frame.x).toBe(0) + + wrapper.unmount() + }) + + it("点击画布空白取消选中", async () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + await wrapper.find(".rd-canvas-element").trigger("mousedown") + await flushPromises() + expect(wrapper.findComponent(ReportProperties).text()).toContain("t1") + + await wrapper.find(".rd-canvas-viewport").trigger("mousedown") + await flushPromises() + expect(wrapper.findComponent(ReportProperties).text()).toContain("未选中元素") + + wrapper.unmount() + }) +}) + +describe("快捷键", () => { + it("Delete 删除选中元素", async () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + await wrapper.find(".rd-canvas-element").trigger("mousedown") + await flushPromises() + expect(wrapper.findAll(".rd-canvas-element").length).toBe(1) + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" })) + await flushPromises() + expect(wrapper.findAll(".rd-canvas-element").length).toBe(0) + + wrapper.unmount() + }) + + it("Ctrl+Z 撤销", async () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + await wrapper.findComponent(ReportToolbox).find("button").trigger("click") + await flushPromises() + expect(wrapper.findAll(".rd-canvas-element").length).toBe(2) + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true })) + await flushPromises() + expect(wrapper.findAll(".rd-canvas-element").length).toBe(1) + + wrapper.unmount() + }) + + it("输入框内按 Delete 不删除画布元素", async () => { + const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } }) + await wrapper.find(".rd-canvas-element").trigger("mousedown") + await flushPromises() + + const input = wrapper.find(".rd-prop-input") + expect(input.exists()).toBe(true) + // 以输入框为 target 冒泡到 window,快捷键必须让位给打字 + input.element.dispatchEvent( + new KeyboardEvent("keydown", { key: "Delete", bubbles: true }), + ) + await flushPromises() + expect(wrapper.findAll(".rd-canvas-element").length).toBe(1) + + wrapper.unmount() + }) +}) diff --git a/code/one-designer/packages/report-designer-vue/tsconfig.json b/code/one-designer/packages/report-designer-vue/tsconfig.json new file mode 100644 index 00000000..31d6714d --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"], + "jsx": "preserve", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "src/**/*.vue"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/code/one-designer/packages/report-designer-vue/tsconfig.test.json b/code/one-designer/packages/report-designer-vue/tsconfig.test.json new file mode 100644 index 00000000..d23fbd05 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"], + "jsx": "preserve", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "*.config.ts"] +} diff --git a/code/one-designer/packages/report-designer-vue/vite.config.ts b/code/one-designer/packages/report-designer-vue/vite.config.ts new file mode 100644 index 00000000..dd248be1 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite" +import vue from "@vitejs/plugin-vue" +import { resolve } from "node:path" + +export default defineConfig({ + plugins: [vue()], + build: { + lib: { + entry: resolve(__dirname, "src/index.ts"), + // 显式指定 es + cjs:缺省的 umd 需要 output.globals, + // 库场景下宿主走打包器消费,不需要 UMD。 + formats: ["es", "cjs"], + fileName: (format) => (format === "es" ? "index.js" : "index.cjs"), + }, + rollupOptions: { + // 工作区兄弟包与 vue 保持 external,由宿主提供。 + external: ["vue", "@report/core", "@report/layout", "@report/designer"], + output: { + // 只用具名导出,避免 ESM/CJS 互操作时 .default 歧义。 + exports: "named", + }, + }, + sourcemap: true, + }, +}) diff --git a/code/one-designer/packages/report-designer-vue/vitest.config.ts b/code/one-designer/packages/report-designer-vue/vitest.config.ts new file mode 100644 index 00000000..d9254911 --- /dev/null +++ b/code/one-designer/packages/report-designer-vue/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config" +import vue from "@vitejs/plugin-vue" + +export default defineConfig({ + plugins: [vue()], + test: { + include: ["tests/**/*.test.ts"], + environment: "jsdom", + }, +}) diff --git a/code/one-designer/packages/report-designer/package.json b/code/one-designer/packages/report-designer/package.json new file mode 100644 index 00000000..3e0f8240 --- /dev/null +++ b/code/one-designer/packages/report-designer/package.json @@ -0,0 +1,34 @@ +{ + "name": "@report/designer", + "version": "0.1.0", + "description": "报表编辑器的状态、命令、选择、坐标转换与交互逻辑(无 UI)", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit -p tsconfig.test.json" + }, + "dependencies": { + "@report/core": "workspace:*", + "@report/layout": "workspace:*" + }, + "devDependencies": { + "@report/renderer": "workspace:*", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/code/one-designer/packages/report-designer/src/commands.ts b/code/one-designer/packages/report-designer/src/commands.ts new file mode 100644 index 00000000..fb54ae4f --- /dev/null +++ b/code/one-designer/packages/report-designer/src/commands.ts @@ -0,0 +1,213 @@ +/** + * Designer 编辑命令(对应设计文档 §4.5)。 + * + * Designer 通过 Core 的命令接口修改文档,不直接实现报表业务规则。 + * 这里的命令都是纯函数:接收文档 + payload,返回新文档(整体替换), + * 由 EditSession 负责历史记录与撤销。 + */ +import type { ReportDocument, ReportElement, Rect } from "@report/core" +import { cloneDocument, createCommand, createElementId } from "@report/core" +import type { Command } from "@report/core" + +/** 移动/缩放元素的 payload。 */ +export interface MoveElementPayload { + /** 元素 ID → 新 frame(绝对坐标,mm)。 */ + frames: Record +} + +/** 修改元素属性的 payload。 */ +export interface UpdateElementPayload { + elementId: string + /** 属性名,如 "name"、"locked"、"hidden"、"props.text"。 */ + path: string + value: unknown +} + +/** 新增元素的 payload。 */ +export interface AddElementPayload { + /** 目标区域 ID。 */ + sectionId: string + /** 新增元素(含 id)。 */ + element: ReportElement + /** 插入位置索引,省略则追加到末尾。 */ + index?: number +} + +/** 删除元素的 payload。 */ +export interface DeleteElementsPayload { + /** 要删除的元素 ID 集合。 */ + elementIds: ReadonlySet | string[] +} + +/** + * 移动元素(可合并:拖拽时连续调用会合并为一步撤销)。 + * + * payload.frames 给出目标 frame,命令把文档中对应元素替换为新 frame。 + */ +export const moveElementsCommand: Command = createCommand( + "designer.move-elements", + (document, payload) => { + const next = cloneDocument(document) + const targets = new Map(Object.entries(payload.frames)) + let moved = 0 + for (const section of next.sections) { + moved += applyFrames(section.children, targets) + } + if (moved === 0) return next + return next + }, + { mergeKey: "move-elements" }, +) + +function applyFrames( + elements: ReportElement[], + targets: Map, +): number { + let count = 0 + for (const element of elements) { + const target = targets.get(element.id) + if (target) { + element.frame = { ...target } + count += 1 + } + if (element.children?.length) count += applyFrames(element.children, targets) + const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns + if (Array.isArray(columns)) { + for (const column of columns) { + if (column.children?.length) count += applyFrames(column.children, targets) + } + } + } + return count +} + +/** + * 修改元素属性。 + * + * path 支持点号分隔的嵌套路径,如 "props.text"、"frame.width"。 + * 用简单解析避免引入深层路径库。 + */ +export const updateElementCommand: Command = createCommand( + "designer.update-element", + (document, payload) => { + const next = cloneDocument(document) + const found = findAndUpdate(next, payload.elementId, payload.path, payload.value) + if (!found) { + return { document: null, diagnostics: [{ severity: "warning", code: "designer.element-missing", message: `未找到元素 "${payload.elementId}"` }] } + } + return next + }, +) + +function findAndUpdate( + document: ReportDocument, + elementId: string, + path: string, + value: unknown, +): boolean { + const walk = (elements: ReportElement[]): boolean => { + for (const element of elements) { + if (element.id === elementId) { + setByPath(element, path, value) + return true + } + if (element.children?.length && walk(element.children)) return true + const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns + if (Array.isArray(columns)) { + for (const column of columns) { + if (column.children?.length && walk(column.children)) return true + } + } + } + return false + } + for (const section of document.sections) { + if (walk(section.children)) return true + } + return false +} + +function setByPath(target: object, path: string, value: unknown): void { + const parts = path.split(".") + let current: Record = target as Record + for (let i = 0; i < parts.length - 1; i += 1) { + const key = parts[i]! + const existing = current[key] + if (existing === null || typeof existing !== "object" || Array.isArray(existing)) { + current[key] = {} + } + current = current[key] as Record + } + current[parts[parts.length - 1]!] = value +} + +/** + * 新增元素到指定区域。 + */ +export const addElementCommand: Command = createCommand( + "designer.add-element", + (document, payload) => { + const next = cloneDocument(document) + const section = next.sections.find((s) => s.id === payload.sectionId) + if (!section) { + return { document: null, diagnostics: [{ severity: "warning", code: "designer.section-missing", message: `未找到区域 "${payload.sectionId}"` }] } + } + const element = cloneElement(payload.element) + if (payload.index !== undefined && payload.index >= 0 && payload.index <= section.children.length) { + section.children.splice(payload.index, 0, element) + } else { + section.children.push(element) + } + return next + }, +) + +function cloneElement(element: ReportElement): ReportElement { + return JSON.parse(JSON.stringify(element)) as ReportElement +} + +/** + * 删除元素集合。 + */ +export const deleteElementsCommand: Command = createCommand( + "designer.delete-elements", + (document, payload) => { + const next = cloneDocument(document) + const ids = new Set(payload.elementIds) + let removed = 0 + for (const section of next.sections) { + removed += removeElements(section.children, ids) + } + return next + }, +) + +function removeElements(elements: ReportElement[], ids: Set): number { + let removed = 0 + for (let i = elements.length - 1; i >= 0; i -= 1) { + const element = elements[i]! + if (ids.has(element.id)) { + elements.splice(i, 1) + removed += 1 + continue + } + if (element.children?.length) removed += removeElements(element.children, ids) + const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns + if (Array.isArray(columns)) { + for (const column of columns) { + if (column.children?.length) removed += removeElements(column.children, ids) + } + } + } + return removed +} + +/** 创建一个新的文本元素(含自动生成的 ID)。 */ +export function createTextElement(frame: Rect, text = "文本"): ReportElement { + return { id: createElementId(), type: "text", frame, props: { text } } +} + +/** 创建一个新的矩形元素。 */ +export function createRectElement(frame: Rect): ReportElement { + return { id: createElementId(), type: "rect", frame } +} diff --git a/code/one-designer/packages/report-designer/src/coords.ts b/code/one-designer/packages/report-designer/src/coords.ts new file mode 100644 index 00000000..19a8dd19 --- /dev/null +++ b/code/one-designer/packages/report-designer/src/coords.ts @@ -0,0 +1,87 @@ +/** + * 坐标转换(对应设计文档 §4.5「画布和坐标转换」、§7)。 + * + * 文档内部统一使用 mm;Designer 显示层使用 px。 + * 转换链路:屏幕 px →(除以 zoom、减 pan)→ 画布 px →(mmToPx)→ mm。 + * + * 注意:这里的"画布 px"指缩放后用于显示的像素,与文档 mm 的换算 + * 走 1in = 96px(§7)。 + */ +import { mmToPx, pxToMm } from "@report/layout" +import type { DesignerState } from "./state.js" + +/** 屏幕坐标(浏览器事件坐标)。 */ +export interface ScreenPoint { + x: number + y: number +} + +/** 文档坐标(mm)。 */ +export interface DocPoint { + x: number + y: number +} + +/** + * 屏幕坐标 → 文档坐标(mm)。 + * + * @param point 屏幕/事件坐标(px) + * @param state 编辑器状态(含 zoom 与 pan) + */ +export function screenToDoc(point: ScreenPoint, state: DesignerState): DocPoint { + // 先减去平移,再除以缩放,得到画布 px + const canvasX = (point.x - state.panX) / state.zoom + const canvasY = (point.y - state.panY) / state.zoom + return { + x: pxToMm(canvasX), + y: pxToMm(canvasY), + } +} + +/** + * 文档坐标(mm)→ 屏幕坐标(px)。 + */ +export function docToScreen(point: DocPoint, state: DesignerState): ScreenPoint { + const canvasX = mmToPx(point.x) + const canvasY = mmToPx(point.y) + return { + x: canvasX * state.zoom + state.panX, + y: canvasY * state.zoom + state.panY, + } +} + +/** + * 判断屏幕点是否落在某元素的 frame 内(命中测试)。 + * + * frame 是文档 mm 坐标,point 是屏幕坐标,因此需要换算后比较。 + */ +export function hitTest( + point: ScreenPoint, + frame: { x: number; y: number; width: number; height: number }, + state: DesignerState, +): boolean { + const doc = screenToDoc(point, state) + return ( + doc.x >= frame.x && + doc.x <= frame.x + frame.width && + doc.y >= frame.y && + doc.y <= frame.y + frame.height + ) +} + +/** + * 吸附:把文档坐标吸附到最近的辅助线。 + * + * V1 只做"吸附到整数 mm"的简化实现,完整辅助线吸附放到后续。 + * 返回吸附后的坐标。 + */ +export function snapToGrid(value: number, state: DesignerState): number { + if (!state.snapEnabled) return value + // 吸附到 1mm 整数 + return Math.round(value) +} + +/** 限制缩放范围。 */ +export function clampZoom(zoom: number): number { + return Math.min(2, Math.max(0.5, zoom)) +} diff --git a/code/one-designer/packages/report-designer/src/editor.ts b/code/one-designer/packages/report-designer/src/editor.ts new file mode 100644 index 00000000..0449a2fc --- /dev/null +++ b/code/one-designer/packages/report-designer/src/editor.ts @@ -0,0 +1,281 @@ +/** + * 编辑器会话(对应设计文档 §4.5)。 + * + * 组合 Core 的 EditSession(命令 + 历史)与 DesignerState(编辑器状态), + * 提供 Designer 直接可用的高层 API: + * 选择、移动、缩放、增删元素、复制粘贴、撤销重做、缩放视图。 + * + * 所有文档修改都通过命令走 EditSession,保证可撤销; + * 所有编辑器状态(选中、缩放、平移)都留在本对象内,不进文档(§3.2)。 + */ +import type { ReportDocument, ReportElement, Rect } from "@report/core" +import { createEditSession, cloneDocument, createElementId } from "@report/core" +import type { EditSession } from "@report/core" +import type { DesignerState, DragState } from "./state.js" +import { + createDesignerState, + setSelection, + clearSelection, + toggleSelection, + findElement, +} from "./state.js" +import { + moveElementsCommand, + updateElementCommand, + addElementCommand, + deleteElementsCommand, +} from "./commands.js" +import { clampZoom, snapToGrid } from "./coords.js" + +/** 编辑器会话配置。 */ +export interface EditorOptions { + /** 初始缩放。默认 1。 */ + zoom?: number +} + +/** + * 编辑器会话。 + * + * 典型用法: + * ```ts + * const editor = new EditorSession(createDocument()) + * editor.select("el-1") + * editor.moveElements({ "el-1": { x: 10, y: 10, width: 50, height: 20 } }) + * editor.undo() + * ``` + */ +export class EditorSession { + private readonly session: EditSession + private state: DesignerState + + constructor(document: ReportDocument, options: EditorOptions = {}) { + this.session = createEditSession(document) + this.state = createDesignerState({ zoom: options.zoom ?? 1 }) + } + + // ------------------------------------------------------------------------- + // 文档访问 + // ------------------------------------------------------------------------- + + /** 当前文档(只读视图)。 */ + getDocument(): ReportDocument { + return this.session.getDocument() + } + + /** 当前编辑器状态。 */ + getState(): DesignerState { + return this.state + } + + // ------------------------------------------------------------------------- + // 选择 + // ------------------------------------------------------------------------- + + /** 选中单个元素。 */ + select(elementId: string): void { + this.state = setSelection(this.state, [elementId]) + } + + /** 多选。 */ + selectMany(elementIds: string[]): void { + this.state = setSelection(this.state, elementIds) + } + + /** + * 切换单个元素的选中态(Ctrl/Cmd + 点击的语义)。 + * + * 与 select 的区别:已选中的会被取消选中,未选中的会被加入。 + */ + toggleSelect(elementId: string): void { + this.state = toggleSelection(this.state, elementId) + } + + /** 清空选中。 */ + clearSelection(): void { + this.state = clearSelection(this.state) + } + + /** 当前选中的元素 ID 列表。 */ + getSelectedIds(): string[] { + return [...this.state.selectedIds] + } + + // ------------------------------------------------------------------------- + // 编辑操作(都通过命令,可撤销) + // ------------------------------------------------------------------------- + + /** 移动/缩放元素(frame 为绝对 mm 坐标)。 */ + moveElements(frames: Record): void { + this.session.execute(moveElementsCommand, { frames }, { label: "移动元素" }) + } + + /** 修改元素属性。 */ + updateElement(elementId: string, path: string, value: unknown): void { + this.session.execute(updateElementCommand, { elementId, path, value }, { label: "修改属性" }) + } + + /** 新增元素。 */ + addElement(sectionId: string, element: ReportElement, index?: number): void { + this.session.execute(addElementCommand, { sectionId, element, index }, { label: "新增元素" }) + } + + /** 删除当前选中的元素。 */ + deleteSelected(): void { + const ids = [...this.state.selectedIds] + if (ids.length === 0) return + this.session.execute(deleteElementsCommand, { elementIds: ids }, { label: "删除元素" }) + this.state = clearSelection(this.state) + } + + /** + * 复制选中的元素到剪贴板(内存中),并粘贴为新元素。 + * + * 剪贴板属于编辑器状态,不进文档。 + */ + private clipboard: ReportElement[] = [] + + /** 复制当前选中元素。 */ + copy(): void { + const doc = this.getDocument() + const selected = [...this.state.selectedIds] + const elements: ReportElement[] = [] + for (const id of selected) { + const found = findElement(doc, id) + if (found) elements.push(found.element) + } + this.clipboard = elements.map((e) => JSON.parse(JSON.stringify(e)) as ReportElement) + } + + /** 粘贴剪贴板中的元素到指定区域(新 ID,偏移 5mm 避免重叠)。 */ + paste(sectionId: string): void { + if (this.clipboard.length === 0) return + const pastedIds: string[] = [] + for (const source of this.clipboard) { + const element = JSON.parse(JSON.stringify(source)) as ReportElement + element.id = createElementId() + element.frame = { + ...element.frame, + x: element.frame.x + 5, + y: element.frame.y + 5, + } + this.addElement(sectionId, element) + pastedIds.push(element.id) + } + // 粘贴后选中新元素,方便紧接着继续拖拽/微调。 + this.state = setSelection(this.state, pastedIds) + } + + // ------------------------------------------------------------------------- + // 撤销 / 重做 + // ------------------------------------------------------------------------- + + /** 撤销。 */ + undo(): boolean { + return this.session.undo() + } + + /** 重做。 */ + redo(): boolean { + return this.session.redo() + } + + canUndo(): boolean { + return this.session.history.canUndo + } + + canRedo(): boolean { + return this.session.history.canRedo + } + + // ------------------------------------------------------------------------- + // 视图(缩放、平移) + // ------------------------------------------------------------------------- + + /** 设置缩放。 */ + setZoom(zoom: number): void { + this.state = { ...this.state, zoom: clampZoom(zoom) } + } + + /** 缩放一档。 */ + zoomIn(): void { + this.setZoom(this.state.zoom * 1.25) + } + + zoomOut(): void { + this.setZoom(this.state.zoom / 1.25) + } + + /** 平移画布。 */ + pan(dx: number, dy: number): void { + this.state = { ...this.state, panX: this.state.panX + dx, panY: this.state.panY + dy } + } + + // ------------------------------------------------------------------------- + // 拖拽 + // ------------------------------------------------------------------------- + + /** 开始拖拽移动:记录选中元素的原始 frame,并开启拖拽事务。 */ + beginDrag(startX: number, startY: number): void { + const doc = this.getDocument() + const originalFrames: Record = {} + for (const id of this.state.selectedIds) { + const found = findElement(doc, id) + if (found) { + originalFrames[id] = { ...found.element.frame } + } + } + this.state = { + ...this.state, + drag: { kind: "move", startX, startY, originalFrames }, + } + // 开启跨事件事务,endDrag 时整体提交。 + // 不用命令自带的 mergeKey 时间窗合并:拖拽中途停顿超过合并窗口 + // 就会把一次拖拽拆成多条撤销记录,撤销时只能回退半截。 + this.session.beginTransaction("移动元素") + } + + /** + * 拖拽更新:根据位移量移动选中元素。 + * + * dx / dy 为**文档坐标(mm)**位移量,由调用方把屏幕像素除以缩放后换算得到。 + * + * 吸附作用在「结果坐标」而非「位移量」上:若吸附位移量,元素会落在 + * 非整数 mm 上;吸附结果坐标才能保证元素真正对齐到 mm 网格。 + * + * 这里走 applyWithinTransaction:只更新文档作为实时预览,不单独记历史, + * 由 endDrag 的事务提交统一落成一步撤销。 + */ + updateDrag(dx: number, dy: number): void { + const drag = this.state.drag + if (!drag) return + const frames: Record = {} + for (const [id, original] of Object.entries(drag.originalFrames)) { + frames[id] = { + x: snapToGrid(original.x + dx, this.state), + y: snapToGrid(original.y + dy, this.state), + width: original.width, + height: original.height, + } + } + this.session.applyWithinTransaction(moveElementsCommand, { frames }) + } + + /** 结束拖拽:提交事务,整个拖拽过程合成一步撤销。 */ + endDrag(): void { + if (this.state.drag === null) return + this.state = { ...this.state, drag: null } + this.session.commitTransaction() + } + + /** 是否正在拖拽。 */ + isDragging(): boolean { + return this.state.drag !== null + } +} + +/** 创建编辑器会话。 */ +export function createEditorSession( + document: ReportDocument, + options: EditorOptions = {}): EditorSession { + return new EditorSession(document, options) +} diff --git a/code/one-designer/packages/report-designer/src/index.ts b/code/one-designer/packages/report-designer/src/index.ts new file mode 100644 index 00000000..f635a203 --- /dev/null +++ b/code/one-designer/packages/report-designer/src/index.ts @@ -0,0 +1,43 @@ +/** + * @report/designer 公共入口(对应设计文档 §4.5)。 + * + * 报表编辑器的状态、命令、坐标转换与交互逻辑(无 UI)。 + * Vue 组件层见 @report/designer-vue。 + * + * 关键边界(§3.2、§4.5): + * - 所有编辑器状态(选中、缩放、平移、拖拽、剪贴板)都留在此包, + * 绝不写入 ReportDocument。 + * - 所有文档修改通过 Core 的命令接口,保证可撤销。 + */ +export type { DesignerState, DragState } from "./state.js" +export { + createDesignerState, + toggleSelection, + setSelection, + clearSelection, + isSelected, + collectAllElementIds, + findElement, + sectionElements, +} from "./state.js" + +export type { ScreenPoint, DocPoint } from "./coords.js" +export { screenToDoc, docToScreen, hitTest, snapToGrid, clampZoom } from "./coords.js" + +export type { + MoveElementPayload, + UpdateElementPayload, + AddElementPayload, + DeleteElementsPayload, +} from "./commands.js" +export { + moveElementsCommand, + updateElementCommand, + addElementCommand, + deleteElementsCommand, + createTextElement, + createRectElement, +} from "./commands.js" + +export type { EditorOptions } from "./editor.js" +export { EditorSession, createEditorSession } from "./editor.js" diff --git a/code/one-designer/packages/report-designer/src/state.ts b/code/one-designer/packages/report-designer/src/state.ts new file mode 100644 index 00000000..bdc8339c --- /dev/null +++ b/code/one-designer/packages/report-designer/src/state.ts @@ -0,0 +1,155 @@ +/** + * 编辑器状态模型(对应设计文档 §4.5、§3.2)。 + * + * 关键边界:以下所有状态都属于「编辑器状态」,明确禁止写入 + * ReportDocument(§3.2)。Designer 持有它们,Core 不感知。 + * + * - 当前选中项(selectedIds) + * - 缩放比例(zoom) + * - 画布滚动/平移(panX / panY) + * - 辅助线、吸附设置 + * - 悬停元素、拖拽进行中状态 + */ +import type { ReportElement, ReportSection } from "@report/core" + +/** 编辑器状态。 */ +export interface DesignerState { + /** 当前选中元素 ID 集合(支持多选)。 */ + selectedIds: ReadonlySet + /** 缩放比例,1 表示 100%。 */ + zoom: number + /** 画布水平平移(px,显示层坐标)。 */ + panX: number + /** 画布垂直平移(px,显示层坐标)。 */ + panY: number + /** 是否启用吸附。 */ + snapEnabled: boolean + /** 吸附阈值(px)。 */ + snapThreshold: number + /** 当前悬停元素 ID(用于高亮)。 */ + hoveredId: string | null + /** 拖拽进行中的临时状态。 */ + drag: DragState | null +} + +/** 拖拽进行中的状态。 */ +export interface DragState { + /** 拖拽类型。 */ + kind: "move" | "resize" + /** 起始指针位置(画布坐标 mm)。 */ + startX: number + startY: number + /** 拖拽起始时各元素的原始 frame(key 为元素 ID)。 */ + originalFrames: Record +} + +/** 创建默认编辑器状态。 */ +export function createDesignerState(partial: Partial = {}): DesignerState { + return { + selectedIds: new Set(), + zoom: 1, + panX: 0, + panY: 0, + snapEnabled: true, + snapThreshold: 5, + hoveredId: null, + drag: null, + ...partial, + } +} + +/** 选中/取消选中一个元素(返回新的 Set,保持不可变)。 */ +export function toggleSelection( + state: DesignerState, + elementId: string, +): DesignerState { + const next = new Set(state.selectedIds) + if (next.has(elementId)) next.delete(elementId) + else next.add(elementId) + return { ...state, selectedIds: next } +} + +/** 设置选中集合(单选或多选)。 */ +export function setSelection( + state: DesignerState, + ids: ReadonlySet | string[], +): DesignerState { + const next = new Set(ids) + return { ...state, selectedIds: next } +} + +/** 清空选中。 */ +export function clearSelection(state: DesignerState): DesignerState { + return { ...state, selectedIds: new Set() } +} + +/** 判断某元素是否被选中。 */ +export function isSelected(state: DesignerState, elementId: string): boolean { + return state.selectedIds.has(elementId) +} + +/** + * 收集文档中所有元素的 ID(含嵌套、表格列内元素)。 + * + * Designer 需要遍历元素树来做命中测试与图层面板。 + */ +export function collectAllElementIds(document: import("@report/core").ReportDocument): string[] { + const ids: string[] = [] + const walk = (elements: readonly ReportElement[]): void => { + for (const element of elements) { + ids.push(element.id) + if (element.children?.length) walk(element.children) + const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns + if (Array.isArray(columns)) { + for (const column of columns) { + if (column.children?.length) walk(column.children) + } + } + } + } + for (const section of document.sections) walk(section.children) + return ids +} + +/** + * 在文档中查找元素(含嵌套),返回元素与它所属的父数组, + * 便于 Designer 做删除/替换操作。 + */ +export function findElement( + document: import("@report/core").ReportDocument, + elementId: string, +): { element: ReportElement; parent: ReportElement[] } | null { + const walk = (elements: ReportElement[]): { element: ReportElement; parent: ReportElement[] } | null => { + for (let i = 0; i < elements.length; i += 1) { + const element = elements[i]! + if (element.id === elementId) { + return { element, parent: elements } + } + if (element.children?.length) { + const found = walk(element.children) + if (found) return found + } + const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns + if (Array.isArray(columns)) { + for (const column of columns) { + if (column.children?.length) { + const found = walk(column.children) + if (found) return found + } + } + } + } + return null + } + + for (const section of document.sections) { + const found = walk(section.children) + if (found) return found + } + return null +} + +/** 获取区域内的顶层元素。 */ +export function sectionElements(section: ReportSection): ReportElement[] { + return section.children +} diff --git a/code/one-designer/packages/report-designer/tests/designer.test.ts b/code/one-designer/packages/report-designer/tests/designer.test.ts new file mode 100644 index 00000000..2c4a12e1 --- /dev/null +++ b/code/one-designer/packages/report-designer/tests/designer.test.ts @@ -0,0 +1,356 @@ +/** + * Designer 编辑器测试(对应设计文档 §4.5)。 + * + * 核心断言: + * - 编辑器状态(选中、缩放、平移)独立于文档(§3.2)。 + * - 所有编辑操作通过命令,可撤销可重做。 + * - 坐标转换在屏幕 px 与文档 mm 之间正确换算。 + */ +import { describe, expect, it, vi } from "vitest" +import { createDocument, createElementId, type ReportDocument } from "@report/core" +import { EditorSession, createEditorSession } from "../src/editor.js" +import { screenToDoc, docToScreen, hitTest, clampZoom, snapToGrid } from "../src/coords.js" +import { createDesignerState } from "../src/state.js" +import { createTextElement, createRectElement } from "../src/commands.js" + +function sampleDoc(): ReportDocument { + const doc = createDocument({ name: "编辑器测试" }) + const detail = doc.sections.find((s) => s.kind === "detail")! + detail.children.push( + createTextElement({ x: 0, y: 0, width: 50, height: 8 }, "标题"), + createRectElement({ x: 0, y: 10, width: 50, height: 20 }), + ) + return doc +} + +describe("编辑器状态独立于文档", () => { + it("选中状态不写入文档", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = doc.sections.find((s) => s.kind === "detail")! + const firstId = detail.children[0]!.id + + editor.select(firstId) + // 文档序列化后不应包含任何选中状态 + const serialized = JSON.stringify(editor.getDocument()) + expect(serialized).not.toContain("selectedIds") + expect(editor.getSelectedIds()).toEqual([firstId]) + }) + + it("缩放与平移不写入文档", () => { + const editor = new EditorSession(sampleDoc()) + editor.setZoom(1.5) + editor.pan(10, 20) + const state = editor.getState() + expect(state.zoom).toBe(1.5) + expect(state.panX).toBe(10) + expect(state.panY).toBe(20) + // 文档本身不含这些字段 + expect(JSON.stringify(editor.getDocument())).not.toContain("zoom") + }) +}) + +describe("选择", () => { + it("选择单个元素", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = doc.sections.find((s) => s.kind === "detail")! + const id = detail.children[0]!.id + editor.select(id) + expect(editor.getSelectedIds()).toEqual([id]) + }) + + it("多选与清空", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = doc.sections.find((s) => s.kind === "detail")! + const ids = detail.children.map((c) => c.id) + editor.selectMany(ids) + expect(editor.getSelectedIds().length).toBe(2) + editor.clearSelection() + expect(editor.getSelectedIds()).toHaveLength(0) + }) +}) + +describe("编辑命令与撤销重做", () => { + it("移动元素可撤销", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = doc.sections.find((s) => s.kind === "detail")! + const id = detail.children[0]!.id + const before = { ...detail.children[0]!.frame } + + editor.select(id) + editor.moveElements({ [id]: { x: 100, y: 100, width: 50, height: 8 } }) + + const afterMove = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame + expect(afterMove.x).toBe(100) + + editor.undo() + const afterUndo = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame + expect(afterUndo.x).toBe(before.x) + + editor.redo() + const afterRedo = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame + expect(afterRedo.x).toBe(100) + }) + + it("新增元素可撤销", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const beforeCount = detail.children.length + + const newEl = createTextElement({ x: 0, y: 0, width: 30, height: 8 }, "新元素") + editor.addElement(detail.id, newEl) + + const afterAdd = editor.getDocument().sections.find((s) => s.kind === "detail")!.children.length + expect(afterAdd).toBe(beforeCount + 1) + + editor.undo() + const afterUndo = editor.getDocument().sections.find((s) => s.kind === "detail")!.children.length + expect(afterUndo).toBe(beforeCount) + }) + + it("删除选中元素", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const beforeCount = detail.children.length + const id = detail.children[0]!.id + + editor.select(id) + editor.deleteSelected() + + const afterDelete = editor.getDocument().sections.find((s) => s.kind === "detail")!.children.length + expect(afterDelete).toBe(beforeCount - 1) + expect(editor.getSelectedIds()).toHaveLength(0) + }) + + it("修改元素属性", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const id = detail.children[0]!.id + + editor.updateElement(id, "props.text", "改后的标题") + const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]! + expect((after.props as { text: string }).text).toBe("改后的标题") + }) +}) + +describe("坐标转换", () => { + it("屏幕 px 与文档 mm 换算(zoom=1, pan=0)", () => { + const state = createDesignerState() + const doc = screenToDoc({ x: 96, y: 96 }, state) + // 96px = 1in = 25.4mm + expect(doc.x).toBeCloseTo(25.4, 2) + expect(doc.y).toBeCloseTo(25.4, 2) + + const screen = docToScreen({ x: 25.4, y: 25.4 }, state) + expect(screen.x).toBeCloseTo(96, 2) + expect(screen.y).toBeCloseTo(96, 2) + }) + + it("缩放影响换算", () => { + const state = createDesignerState({ zoom: 2 }) + const doc = screenToDoc({ x: 96, y: 0 }, state) + // 96px / 2 = 48px = 12.7mm + expect(doc.x).toBeCloseTo(12.7, 2) + }) + + it("命中测试", () => { + const state = createDesignerState() + const frame = { x: 0, y: 0, width: 25.4, height: 25.4 } + // 中心点 (48,48)px = 12.7mm 在 frame 内 + expect(hitTest({ x: 48, y: 48 }, frame, state)).toBe(true) + // 外部点不在 + expect(hitTest({ x: 200, y: 200 }, frame, state)).toBe(false) + }) + + it("缩放钳制在 [0.5, 2]", () => { + expect(clampZoom(0.1)).toBe(0.5) + expect(clampZoom(5)).toBe(2) + expect(clampZoom(1)).toBe(1) + }) +}) + +describe("拖拽", () => { + it("拖拽移动选中元素", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const id = detail.children[0]!.id + const before = { ...detail.children[0]!.frame } + + editor.select(id) + editor.beginDrag(0, 0) + editor.updateDrag(10, 20) + editor.endDrag() + + const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame + expect(after.x).toBe(before.x + 10) + expect(after.y).toBe(before.y + 20) + expect(editor.isDragging()).toBe(false) + }) + + it("吸附作用在结果坐标上,拖动落点为整数 mm", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const id = detail.children[0]!.id + + editor.select(id) + editor.beginDrag(0, 0) + editor.updateDrag(10.4, 20.6) + editor.endDrag() + + const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame + // 起点 x=0 y=0,位移取整后应为 10 / 21 + expect(after.x).toBe(10) + expect(after.y).toBe(21) + }) + + it("吸附取整的是落点而非位移量", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const id = detail.children[1]!.id + + // 关键:起点必须是非整数,否则「取整落点」与「取整位移量」结果相同, + // 这个用例就区分不出两种实现。 + editor.moveElements({ [id]: { x: 5.4, y: 10.4, width: 50, height: 20 } }) + + editor.select(id) + editor.beginDrag(0, 0) + editor.updateDrag(0.3, 0.3) + editor.endDrag() + + const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[1]!.frame + // 落点 5.7 → 6、10.7 → 11。 + // 若改成对位移量取整,结果会是 5.4 和 10.4(位移 0.3 取整为 0),与期望不符。 + expect(after.x).toBe(6) + expect(after.y).toBe(11) + }) +}) + +describe("拖拽的事务语义", () => { + it("拖拽中途停顿超过时间窗,仍然只产生一步撤销", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")) + try { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const id = detail.children[0]!.id + const frame = () => + editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame + + editor.select(id) + editor.beginDrag(0, 0) + editor.updateDrag(10, 10) + + // 模拟用户拖到一半停手,闲置远超 History 的 500ms 合并窗口后继续拖。 + // 若靠 mergeKey 时间窗合并,这里会被拆成两条撤销记录。 + vi.setSystemTime(new Date("2026-01-01T00:00:10Z")) + editor.updateDrag(30, 30) + editor.endDrag() + + expect(frame().x).toBe(30) + + // 关键:一次撤销直接回到原位,而不是停在中间态 10 + editor.undo() + expect(frame().x).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it("拖拽没有产生位移时不写入撤销记录", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const id = detail.children[0]!.id + + editor.select(id) + editor.beginDrag(0, 0) + editor.endDrag() + + expect(editor.canUndo()).toBe(false) + }) +}) + +describe("吸附开关(snapToGrid)", () => { + it("开启时取整到整数 mm", () => { + const state = createDesignerState({ snapEnabled: true }) + expect(snapToGrid(10.4, state)).toBe(10) + expect(snapToGrid(10.6, state)).toBe(11) + }) + + it("关闭时原样返回", () => { + const state = createDesignerState({ snapEnabled: false }) + expect(snapToGrid(10.4, state)).toBe(10.4) + expect(snapToGrid(-3.7, state)).toBe(-3.7) + }) +}) + +describe("多选切换与剪贴板", () => { + it("toggleSelect 在选中与取消之间切换", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const first = detail.children[0]!.id + const second = detail.children[1]!.id + + editor.toggleSelect(first) + expect(editor.getSelectedIds()).toEqual([first]) + + // 再切一次同一个 → 取消选中 + editor.toggleSelect(first) + expect(editor.getSelectedIds()).toHaveLength(0) + + // 两个都切上 → 多选 + editor.toggleSelect(first) + editor.toggleSelect(second) + expect(editor.getSelectedIds().sort()).toEqual([first, second].sort()) + }) + + it("粘贴后选中新元素,而不是清空选中", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const sourceId = detail.children[0]!.id + const beforeCount = detail.children.length + + editor.select(sourceId) + editor.copy() + editor.paste(detail.id) + + const afterChildren = editor.getDocument().sections.find((s) => s.kind === "detail")!.children + expect(afterChildren.length).toBe(beforeCount + 1) + + // 回归:旧实现这里会把选中清空 + const selectedIds = editor.getSelectedIds() + expect(selectedIds).toHaveLength(1) + expect(selectedIds[0]).not.toBe(sourceId) + // 选中的确实是刚粘贴出来的那个 + expect(afterChildren.some((c) => c.id === selectedIds[0])).toBe(true) + }) + + it("粘贴的元素相对源元素偏移 5mm 避免重叠", () => { + const doc = sampleDoc() + const editor = new EditorSession(doc) + const detail = editor.getDocument().sections.find((s) => s.kind === "detail")! + const source = detail.children[0]! + + editor.select(source.id) + editor.copy() + editor.paste(detail.id) + + const pasted = editor.getDocument().sections + .find((s) => s.kind === "detail")! + .children.find((c) => c.id === editor.getSelectedIds()[0])! + expect(pasted.frame.x).toBe(source.frame.x + 5) + expect(pasted.frame.y).toBe(source.frame.y + 5) + }) +}) diff --git a/code/one-designer/packages/report-designer/tests/smoke.test.ts b/code/one-designer/packages/report-designer/tests/smoke.test.ts new file mode 100644 index 00000000..b341d0ff --- /dev/null +++ b/code/one-designer/packages/report-designer/tests/smoke.test.ts @@ -0,0 +1,91 @@ +/** + * 端到端链路冒烟测试(§17 V1 验收标准)。 + * + * 验证完整链路: + * ReportDocument(core) + * → 数据注入 + 布局(layout,含分组/聚合) + * → LayoutDocument + * → HTML/SVG Renderer(renderer) + * → Designer 编辑(designer) + */ +import { describe, expect, it } from "vitest" +import { createDocument, createTableElement } from "@report/core" +import { createEstimateMeasurer, createPassthroughFontResolver, layoutDocument } from "@report/layout" +import { renderHtml, renderSvg, createPdfRenderer } from "@report/renderer" +import { EditorSession, createTextElement } from "@report/designer" + +const measurer = createEstimateMeasurer() +const fontResolver = createPassthroughFontResolver() + +describe("端到端冒烟(§17)", () => { + it("完整链路:建文档 → 布局 → HTML/SVG → Designer 编辑", async () => { + // 1. Core:创建文档 + 明细表 + const doc = createDocument({ name: "销售单" }) + doc.datasets = { sales: { id: "sales" } } + const detail = doc.sections.find((s) => s.kind === "detail")! + detail.children.push( + createTableElement("tbl", { x: 0, y: 0, width: 180, height: 20 }, "sales", [ + { id: "c1", width: 90, header: "商品", binding: { field: "name" } }, + { id: "c2", width: 90, header: "金额", binding: { field: "amount" }, align: "right" }, + ]), + ) + const header = doc.sections.find((s) => s.kind === "page-header")! + header.children.push({ + id: "title", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 8 }, + props: { text: "销售单" }, + }) + + const rows = [ + { name: "苹果", amount: 100 }, + { name: "香蕉", amount: 200 }, + { name: "橙子", amount: 300 }, + ] + + // 2. Layout:数据注入 + 布局 + const layout = await layoutDocument( + doc, + { data: { sales: rows }, parameters: { } }, + { textMeasurer: measurer, fontResolver }, + ) + expect(layout.pageCount).toBeGreaterThanOrEqual(1) + expect(layout.diagnostics.filter((d) => d.severity === "error")).toHaveLength(0) + + // 3. Renderer:HTML + SVG 渲染,页数一致 + const html = renderHtml(layout) + const svg = renderSvg(layout) + expect(html.pageCount).toBe(layout.pageCount) + expect(svg.pageCount).toBe(layout.pageCount) + expect(html.content).toContain("销售单") + expect(html.content).toContain("苹果") + expect(svg.content).toContain("苹果") + + // PDF 缺后端 → 诊断而非抛异常 + const pdf = createPdfRenderer().render(layout) + expect(pdf.target).toBe("pdf") + expect(pdf.diagnostics.length).toBeGreaterThan(0) + + // 4. Designer:编辑(移动 + 撤销) + const editor = new EditorSession(doc) + editor.select("title") + const before = editor.getDocument().sections + .find((s) => s.kind === "page-header")!.children[0]!.frame + editor.moveElements({ title: { x: 20, y: 0, width: 100, height: 8 } }) + const afterMove = editor.getDocument().sections + .find((s) => s.kind === "page-header")!.children[0]!.frame + expect(afterMove.x).toBe(20) + editor.undo() + const afterUndo = editor.getDocument().sections + .find((s) => s.kind === "page-header")!.children[0]!.frame + expect(afterUndo.x).toBe(before.x) + + // 新增元素 + const beforeCount = editor.getDocument().sections + .find((s) => s.kind === "detail")!.children.length + editor.addElement(detail.id, createTextElement({ x: 0, y: 0, width: 30, height: 8 }, "备注")) + const afterAdd = editor.getDocument().sections + .find((s) => s.kind === "detail")!.children.length + expect(afterAdd).toBe(beforeCount + 1) + }) +}) diff --git a/code/one-designer/packages/report-designer/tsconfig.json b/code/one-designer/packages/report-designer/tsconfig.json new file mode 100644 index 00000000..23cc1372 --- /dev/null +++ b/code/one-designer/packages/report-designer/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/code/one-designer/packages/report-designer/tsconfig.test.json b/code/one-designer/packages/report-designer/tsconfig.test.json new file mode 100644 index 00000000..6cefc694 --- /dev/null +++ b/code/one-designer/packages/report-designer/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "*.config.ts"] +} diff --git a/code/one-designer/packages/report-designer/tsup.config.ts b/code/one-designer/packages/report-designer/tsup.config.ts new file mode 100644 index 00000000..be47bf93 --- /dev/null +++ b/code/one-designer/packages/report-designer/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + target: "es2022", +}) diff --git a/code/one-designer/packages/report-expression/README.md b/code/one-designer/packages/report-expression/README.md new file mode 100644 index 00000000..c602289d --- /dev/null +++ b/code/one-designer/packages/report-expression/README.md @@ -0,0 +1,186 @@ +# @report/expression + +受限表达式的词法、语法、AST 与求值器。**不执行任意 JavaScript。** + +> 对应设计文档 §4.2、§10。 + +## 定位 + +表达式系统使用**受限语法和 AST**,禁止通过 `eval` 或 `new Function` 执行任意 JavaScript(§4.2)。 + +本包只依赖 `@report/core` 的类型,不依赖 DOM、不依赖任何求值框架。 + +## 安装与使用 + +```ts +import { evaluateExpression, compile, evaluateCompiled, ExpressionCache } from "@report/expression" + +// 一次性求值 +const { value, diagnostics } = evaluateExpression("row.price * row.quantity", { + scope: { row: { price: 3, quantity: 4 } }, +}) +// value === 12 + +// 预编译(明细表每行求值一次时使用,避免重复解析) +const compiled = compile("if(row.amount > 0, '有', '无')") +evaluateCompiled(compiled, { scope: { row: { amount: 5 } } }) // "有" +``` + +## 安全边界 + +这是本包最重要的设计约束。安全通过**五层**保证: + +1. **AST 即全部** — 求值器只处理 `ast.ts` 中定义的 9 种节点,没有其它可执行形式。 +2. **作用域白名单** — 根标识符只认识 `row` / `params` / `group` / `page` / `index` / `rownumber` / `dataset`。 + `window`、`globalThis`、`process`、`document` 一律解析为 `undefined`。 +3. **原型链防护** — 属性访问拒绝 `__proto__`、`constructor`、`prototype`, + 因此 `row.constructor.constructor` 拿不到 `Function`。 +4. **函数白名单** — 解析期即拒绝未登记的函数调用,非法表达式在任何求值之前就失败。 +5. **异常兜底** — 求值过程中的任何异常都转成诊断,不向上抛(§13)。 + +```ts +parse("eval('1+1')") // null,报"不允许调用函数" +parse("row.constructor") // 求值为 undefined +parse("row.__proto__") // 求值为 undefined +parse("`${row.amount}`") // null,模板字符串不受支持 +parse("(x) => x") // null,箭头函数不受支持 +parse("row.amount = 1") // null,赋值不受支持 +parse("row.name.toUpperCase()") // null,成员方法调用不受支持 +``` + +字符串与数组**不提供任何方法**,只能读 `length` 与下标。 + +## 语法 + +``` +conditional ?: +logical-or || +logical-and && +nullish ?? +equality == != === !== +relational > >= < <= +additive + - +multiplicative * / % +unary - + ! +postfix . [] () +primary 字面量 / 标识符 / ( expr ) +``` + +字段访问是表达式求值的核心用法: + +``` +row.amount 字段 +params.customerName 参数 +row.price * row.quantity 运算 +if(row.amount > 0, "有", "无") 条件 +format(row.date, "YYYY-MM-DD") 格式化 +``` + +## 内置函数 + +| 分类 | 函数 | +| --- | --- | +| 逻辑 | `if` `and` `or` `not` `isnull` `coalesce` | +| 字符串 | `concat` `len` `upper` `lower` `trim` `substr` `replace` | +| 数学 | `round` `floor` `ceil` `abs` `min` `max` | +| 转换 | `number` `string` `date` | +| 格式化 | `format` | + +`if` 是**惰性求值**:只求值命中的分支,未命中分支的错误不会影响结果。 + +`format` 同时支持日期与数字模式: + +```ts +format(row.date, "YYYY-MM-DD") // 2024-03-05 +format(1234567.891, "#,##0.00") // 1,234,567.89 +format(0.1234, "0.0%") // 12.3% +``` + +`replace` 只做**字面量替换**,不接受正则,避免 ReDoS 与转义歧义: + +```ts +replace("a.b.c", ".", "-") // "a-b-c" +replace("abc", ".", "X") // "abc"('.' 只匹配字面点) +``` + +`date` 返回 ISO **字符串**而非 `Date` 对象,保证表达式结果始终是可序列化的纯数据(§3.5)。 + +## 空值语义 + +报表场景下空单元格很常见,因此约定: + +- `null` / `undefined` 参与算术视为 **0**(避免整列求和变空)。 +- 与字符串相加按拼接处理,`null` 视为空串。 +- 除数为 0 返回 `null` 并产生诊断,**不产生 `Infinity`**。 +- 字段缺失返回 `undefined` 并产生 `data.dataset-missing` 诊断。 + +## 错误处理 + +单个字段错误不应导致整个文档无结果(§10)。所有 API 都返回结果对象而非抛异常: + +```ts +const { value, diagnostics } = evaluateExpression("1 +", { + scope, + elementId: "el-1", + path: "/sections/1/children/0/binding/expression", +}) +// value === null +// diagnostics[0].code === "expression.parse-error" +// diagnostics[0].elementId === "el-1" +``` + +## 值绑定 + +`binding.ts` 把文档模型里的 `ValueBinding { field, expression, format }` 解析成最终值。 +`field` 是 `expression` 的语法糖(`fieldToExpression("amount")` → `"row.amount"`), +两条路径行为完全一致。 + +```ts +import { resolveBinding, ExpressionCache } from "@report/expression" + +const cache = new ExpressionCache() +resolveBinding( + { field: "amount", format: "#,##0.00" }, + { scope: { row: { amount: 1234.5 } } }, + cache, +) +// { value: 1234.5, text: "1,234.50", diagnostics: [] } +``` + +## 目录结构 + +``` +src/ +├── tokenizer.ts 词法分析(最长匹配运算符、中文标识符) +├── ast.ts AST 节点定义与遍历工具 +├── parser.ts 递归下降解析器 + 优先级爬升 +├── scope.ts 求值作用域与安全属性访问 +├── builtins.ts 内置函数(纯函数) +├── evaluator.ts 受限求值器 +├── binding.ts ValueBinding 求值与格式化 +└── index.ts 公共出口 +``` + +## 开发 + +```bash +pnpm build # tsup 打包 +pnpm test # vitest +pnpm typecheck # tsc --noEmit +``` + +## 当前测试覆盖 + +`tests/expression.test.ts` 共 **83** 个用例,其中 20 个专门验证安全边界: + +- 禁止任意代码执行:`eval`、`Function`、`require`、`import()`、宿主 API +- 原型链逃逸:`constructor`、`__proto__`、`prototype` 及其组合 +- 不支持的语法:模板字符串、箭头函数、赋值、`new`、`this`、注释 +- 作用域白名单与 `extra` 自定义根 +- 运算语义、空值处理、内置函数、格式化 +- 错误诊断与编译缓存 + +## 尚未实现 + +- 聚合函数(`sum` / `avg` / `count`),需要配合分组的上下文聚合(阶段 3)。 +- 自定义函数注册接口(当前白名单为编译期常量)。 diff --git a/code/one-designer/packages/report-expression/package.json b/code/one-designer/packages/report-expression/package.json new file mode 100644 index 00000000..57860528 --- /dev/null +++ b/code/one-designer/packages/report-expression/package.json @@ -0,0 +1,32 @@ +{ + "name": "@report/expression", + "version": "0.1.0", + "description": "受限表达式的词法、语法、AST 与求值器", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit -p tsconfig.test.json" + }, + "dependencies": { + "@report/core": "workspace:*" + }, + "devDependencies": { + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/code/one-designer/packages/report-expression/src/ast.ts b/code/one-designer/packages/report-expression/src/ast.ts new file mode 100644 index 00000000..ddda406b --- /dev/null +++ b/code/one-designer/packages/report-expression/src/ast.ts @@ -0,0 +1,228 @@ +/** + * 表达式 AST(对应设计文档 §10)。 + * + * 只有这一组节点类型可以被求值器执行,杜绝了运行任意代码的可能。 + */ + +export type NodeType = + | "Literal" + | "Identifier" + | "Member" + | "Index" + | "Call" + | "Unary" + | "Binary" + | "Logical" + | "Conditional" + +/** 源码位置,用于诊断定位。 */ +export interface SourceSpan { + start: number + end: number +} + +interface BaseNode { + type: NodeType + span: SourceSpan +} + +/** 字面量:数字 / 字符串 / 布尔 / null。 */ +export interface LiteralNode extends BaseNode { + type: "Literal" + value: string | number | boolean | null +} + +/** 标识符:作用域根,如 row、params、page。 */ +export interface IdentifierNode extends BaseNode { + type: "Identifier" + name: string +} + +/** 成员访问:a.b。 */ +export interface MemberNode extends BaseNode { + type: "Member" + object: ExpressionNode + property: string +} + +/** 下标访问:a[0]。 */ +export interface IndexNode extends BaseNode { + type: "Index" + object: ExpressionNode + index: ExpressionNode +} + +/** 函数调用:f(a, b)。 */ +export interface CallNode extends BaseNode { + type: "Call" + callee: string + args: ExpressionNode[] +} + +/** 一元运算:-a、!a。 */ +export interface UnaryNode extends BaseNode { + type: "Unary" + operator: "-" | "+" | "!" + argument: ExpressionNode +} + +/** 二元运算:a + b。 */ +export interface BinaryNode extends BaseNode { + type: "Binary" + operator: "+" | "-" | "*" | "/" | "%" | "==" | "!=" | "===" | "!==" | ">" | ">=" | "<" | "<=" + left: ExpressionNode + right: ExpressionNode +} + +/** 逻辑运算:a && b、a || b、a ?? b。 */ +export interface LogicalNode extends BaseNode { + type: "Logical" + operator: "&&" | "||" | "??" + left: ExpressionNode + right: ExpressionNode +} + +/** 三元条件:cond ? a : b。 */ +export interface ConditionalNode extends BaseNode { + type: "Conditional" + test: ExpressionNode + consequent: ExpressionNode + alternate: ExpressionNode +} + +export type ExpressionNode = + | LiteralNode + | IdentifierNode + | MemberNode + | IndexNode + | CallNode + | UnaryNode + | BinaryNode + | LogicalNode + | ConditionalNode + +/** 解析错误。 */ +export interface ParseError { + message: string + span: SourceSpan +} + +/** 解析结果。 */ +export interface ParseResult { + ast: ExpressionNode | null + error: ParseError | null +} + +/** 遍历 AST 的所有子节点,供分析与校验使用。 */ +export function walkNode( + node: ExpressionNode, + visit: (child: ExpressionNode) => void, +): void { + switch (node.type) { + case "Literal": + case "Identifier": + return + case "Member": + visit(node.object) + return + case "Index": + visit(node.object) + visit(node.index) + return + case "Call": + for (const arg of node.args) visit(arg) + return + case "Unary": + visit(node.argument) + return + case "Binary": + case "Logical": + visit(node.left) + visit(node.right) + return + case "Conditional": + visit(node.test) + visit(node.consequent) + visit(node.alternate) + return + } +} + +/** + * 收集表达式中引用的作用域根标识符。 + * + * 例如 `row.amount + params.tax` 会返回 ["row", "params"]。 + * 供宿主在绑定前校验作用域是否可用。 + */ +export function collectRootIdentifiers(node: ExpressionNode): Set { + const roots = new Set() + const visitRoot = (current: ExpressionNode, isRoot: boolean): void => { + if (current.type === "Identifier") { + if (isRoot) roots.add(current.name) + return + } + switch (current.type) { + case "Member": + visitRoot(current.object, isRoot) + return + case "Index": + visitRoot(current.object, isRoot) + visitRoot(current.index, false) + return + case "Call": + for (const arg of current.args) visitRoot(arg, false) + return + case "Unary": + visitRoot(current.argument, false) + return + case "Binary": + case "Logical": + visitRoot(current.left, false) + visitRoot(current.right, false) + return + case "Conditional": + visitRoot(current.test, false) + visitRoot(current.consequent, false) + visitRoot(current.alternate, false) + return + case "Literal": + return + } + } + visitRoot(node, true) + return roots +} + +/** 收集表达式调用的所有函数名。 */ +export function collectFunctionNames(node: ExpressionNode): Set { + const names = new Set() + const visit = (current: ExpressionNode): void => { + if (current.type === "Call") names.add(current.callee) + walkNode(current, visit) + } + visit(node) + return names +} + +/** 把 AST 重新打印为表达式源码,便于诊断信息展示。 */ +export function printNode(node: ExpressionNode): string { + switch (node.type) { + case "Literal": + return typeof node.value === "string" ? JSON.stringify(node.value) : String(node.value) + case "Identifier": + return node.name + case "Member": + return `${printNode(node.object)}.${node.property}` + case "Index": + return `${printNode(node.object)}[${printNode(node.index)}]` + case "Call": + return `${node.callee}(${node.args.map(printNode).join(", ")})` + case "Unary": + return `${node.operator}${printNode(node.argument)}` + case "Binary": + case "Logical": + return `(${printNode(node.left)} ${node.operator} ${printNode(node.right)})` + case "Conditional": + return `(${printNode(node.test)} ? ${printNode(node.consequent)} : ${printNode(node.alternate)})` + } +} diff --git a/code/one-designer/packages/report-expression/src/binding.ts b/code/one-designer/packages/report-expression/src/binding.ts new file mode 100644 index 00000000..ce5a26ae --- /dev/null +++ b/code/one-designer/packages/report-expression/src/binding.ts @@ -0,0 +1,167 @@ +/** + * 值绑定求值(对应设计文档 §6.2 的 ValueBinding 与 §10)。 + * + * 把文档里的 ValueBinding { field, expression, format } 解析成最终值。 + * 这是 report-layout 消费绑定的统一入口,保证字段与表达式两条路径 + * 行为一致(都走受限求值器,字段只是 expression 的语法糖)。 + */ +import type { Diagnostic } from "@report/core" +import type { ValueBinding } from "@report/core" +import { DIAGNOSTIC_CODES } from "@report/core" +import { compile, evaluateCompiled, type CompiledExpression } from "./evaluator.js" +import type { EvalScope } from "./scope.js" + +/** 绑定求值选项。 */ +export interface ResolveBindingOptions { + scope: EvalScope + elementId?: string + path?: string +} + +/** 绑定求值结果。 */ +export interface ResolveBindingResult { + value: unknown + /** 格式化后的展示文本。 */ + text: string + diagnostics: Diagnostic[] +} + +/** + * 编译缓存。 + * + * 同一表达式在明细表里会对每一行求值一次,缓存解析结果避免重复解析。 + * 使用 Map 而非模块级单例,由调用方决定生命周期。 + */ +export class ExpressionCache { + private readonly cache = new Map() + + get(source: string): CompiledExpression { + const existing = this.cache.get(source) + if (existing) return existing + const compiled = compile(source) + this.cache.set(source, compiled) + return compiled + } + + clear(): void { + this.cache.clear() + } + + get size(): number { + return this.cache.size + } +} + +/** + * 把 field 路径转为表达式源码。 + * + * field 形如 "amount" 或 "customer.name",统一求值为 row.。 + */ +export function fieldToExpression(field: string): string { + return `row.${field}` +} + +/** + * 解析一个值绑定。 + * + * 优先级:expression > field。两者都缺省时返回空值。 + * format 只影响 text,不影响 value,便于 Renderer 自行决定呈现方式。 + */ +export function resolveBinding( + binding: ValueBinding | undefined, + options: ResolveBindingOptions, + cache?: ExpressionCache, +): ResolveBindingResult { + if (!binding) { + return { value: null, text: "", diagnostics: [] } + } + + const diagnostics: Diagnostic[] = [] + let value: unknown = null + + if (binding.expression) { + const compiled = cache ? cache.get(binding.expression) : compile(binding.expression) + const result = evaluateCompiled(compiled, options) + value = result.value + diagnostics.push(...result.diagnostics) + } else if (binding.field) { + const source = fieldToExpression(binding.field) + const compiled = cache ? cache.get(source) : compile(source) + const result = evaluateCompiled(compiled, options) + value = result.value + diagnostics.push(...result.diagnostics) + + // 字段缺失通常意味着数据源与设计不一致,值得单独提示 + if (value === undefined) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.DATA_DATASET_MISSING, + message: `字段 "${binding.field}" 在当前数据行中不存在`, + ...(options.elementId !== undefined ? { elementId: options.elementId } :{}), + ...(options.path !== undefined ? { path: options.path } :{}), + }) + } + } + + const text = formatValue(value, binding.format, options, cache) + diagnostics.push(...text.diagnostics) + + return { value, text: text.text, diagnostics } +} + +interface FormatResult { + text: string + diagnostics: Diagnostic[] +} + +/** + * 格式化值。 + * + * 缺省行为:null / undefined 渲染为空串,其余走 String()。 + * 指定 format 时复用表达式的 format() 内置函数,保证与表达式内 + * format() 的行为完全一致。 + */ +export function formatValue( + value: unknown, + format: string | undefined, + options: ResolveBindingOptions, + cache?: ExpressionCache, +): FormatResult { + if (value === null || value === undefined) return { text: "", diagnostics: [] } + + if (!format) { + return { text: defaultStringify(value), diagnostics: [] } + } + + // 复用 format() 内置函数,避免两套格式化逻辑产生分歧 + const source = `format(row.__value, ${JSON.stringify(format)})` + const compiled = cache ? cache.get(source) : compile(source) + const result = evaluateCompiled(compiled, { + ...options, + scope: { + ...options.scope, + row: { ...(options.scope.row ?? {}), __value: value }, + }, + }) + + return { + text: result.value === null ? "" : defaultStringify(result.value), + diagnostics: result.diagnostics, + } +} + +function defaultStringify(value: unknown): string { + if (value === null || value === undefined) return "" + if (typeof value === "string") return value + if (typeof value === "number") return Number.isFinite(value) ? String(value) : "" + if (typeof value === "boolean") return value ? "true" : "false" + if (value instanceof Date) return value.toISOString() + // 对象与数组不做隐式展开,避免把内部结构写进报表 + return "" +} + +/** 判断绑定是否为空(无 field 也无 expression)。 */ +export function isBindingEmpty(binding: ValueBinding | undefined): boolean { + if (!binding) return true + return !binding.field && !binding.expression +} diff --git a/code/one-designer/packages/report-expression/src/builtins.ts b/code/one-designer/packages/report-expression/src/builtins.ts new file mode 100644 index 00000000..062db3e3 --- /dev/null +++ b/code/one-designer/packages/report-expression/src/builtins.ts @@ -0,0 +1,355 @@ +/** + * 内置函数(对应设计文档 §10 与 §4.2)。 + * + * V1 至少支持: + * 字段:row.amount + * 参数:params.customerName + * 简单运算:row.price * row.quantity + * 条件:if(row.amount > 0, "有", "无") + * 格式化:format(row.date, "YYYY-MM-DD") + * + * 所有函数都是纯函数,除参数外不接触任何外部状态。 + */ + +/** 函数实现签名。 */ +export type BuiltinFunction = (args: unknown[]) => unknown + +function toNumber(value: unknown): number { + if (typeof value === "number") return value + if (typeof value === "boolean") return value ? 1 : 0 + if (typeof value === "string") { + const trimmed = value.trim() + if (trimmed === "") return 0 + const parsed = Number(trimmed) + return Number.isNaN(parsed) ? Number.NaN : parsed + } + if (value === null || value === undefined) return 0 + return Number.NaN +} + +function toString(value: unknown): string { + if (value === null || value === undefined) return "" + if (typeof value === "string") return value + if (typeof value === "number" || typeof value === "boolean") return String(value) + // 对象不做隐式序列化,避免把内部结构意外写进报表 + return "" +} + +/** 把 Date 或可解析的日期值规范化为 Date。 */ +function toDate(value: unknown): Date | null { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value + if (typeof value === "number") { + const d = new Date(value) + return Number.isNaN(d.getTime()) ? null : d + } + if (typeof value === "string" && value.trim() !== "") { + // 优先接受 ISO;再尝试 YYYY-MM-DD / YYYY/MM/DD + const iso = new Date(value) + if (!Number.isNaN(iso.getTime())) return iso + } + return null +} + +function pad(value: number, length = 2): string { + return String(Math.abs(value)).padStart(length, "0") +} + +/** + * 按模式格式化日期。 + * + * 支持 YYYY MM DD HH mm ss SSS,以及常见的 M D H m s 简写。 + * 小时使用 HH(24 小时制),避免 AM/PM 带来的歧义。 + */ +function formatDate(date: Date, pattern: string): string { + const tokens: Record = { + YYYY: String(date.getFullYear()), + MM: pad(date.getMonth() + 1), + DD: pad(date.getDate()), + HH: pad(date.getHours()), + mm: pad(date.getMinutes()), + ss: pad(date.getSeconds()), + SSS: String(date.getMilliseconds()).padStart(3, "0"), + M: String(date.getMonth() + 1), + D: String(date.getDate()), + H: String(date.getHours()), + } + // 长 token 优先,防止 MM 被 M 抢先匹配 + return pattern.replace(/YYYY|SSS|MM|DD|HH|mm|ss|M|D|H/g, (match) => tokens[match] ?? match) +} + +/** + * 按模式格式化数字。 + * + * 支持 #,##0.00 这类千分位与小位数模式,以及 0%、0.0% 百分比。 + */ +function formatNumber(value: number, pattern: string): string { + if (!Number.isFinite(value)) return "" + + // 百分比模式 + if (pattern.includes("%")) { + const decimals = (pattern.split(".")[1] ?? "").replace("%", "").length + return `${(value * 100).toFixed(decimals)}%` + } + + const decimalPart = pattern.split(".")[1] ?? "" + const decimals = decimalPart.replace(/[^0#]/g, "").length + const useGrouping = pattern.includes(",") + + let text = value.toFixed(decimals) + if (useGrouping) { + const negative = text.startsWith("-") + if (negative) text = text.slice(1) + const [intPart = "", fracPart] = text.split(".") + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + text = fracPart !== undefined ? `${grouped}.${fracPart}` : grouped + if (negative) text = `-${text}` + } + return text +} + +/** 内置函数表。必须与 parser 的白名单保持一致。 */ +export const BUILTINS: Record = { + /** 条件:if(cond, a, b)。惰性由求值器保证。 */ + if: (args) => (args[0] ? args[1] : args[2]), + + /** 逻辑与:全部为真才为真。 */ + and: (args) => args.every((a) => Boolean(a)), + + /** 逻辑或:任一为真即为真。 */ + or: (args) => args.some((a) => Boolean(a)), + + /** 逻辑非。 */ + not: (args) => !args[0], + + /** 字符串拼接,null/undefined 视为空串。 */ + concat: (args) => args.map(toString).join(""), + + /** + * 格式化:format(value, pattern)。 + * + * 值是日期时按日期模式格式化,是数字时按数字模式格式化。 + */ + format: (args) => { + const value = args[0] + const pattern = toString(args[1]) + if (!pattern) return toString(value) + + const date = toDate(value) + // 含日期 token 的模式走日期格式化;纯数字模式走数字格式化 + if (date && /Y|M|D|H|m|s/.test(pattern)) { + return formatDate(date, pattern) + } + if (typeof value === "number" || (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value)))) { + return formatNumber(toNumber(value), pattern) + } + return toString(value) + }, + + round: (args) => { + const value = toNumber(args[0]) + const digits = args.length > 1 ? toNumber(args[1]) : 0 + if (!Number.isFinite(value)) return null + const factor = 10 ** digits + return Math.round(value * factor) / factor + }, + + floor: (args) => { + const value = toNumber(args[0]) + return Number.isFinite(value) ? Math.floor(value) : null + }, + + ceil: (args) => { + const value = toNumber(args[0]) + return Number.isFinite(value) ? Math.ceil(value) : null + }, + + abs: (args) => { + const value = toNumber(args[0]) + return Number.isFinite(value) ? Math.abs(value) : null + }, + + /** + * 最小值。 + * + * 同时支持两种调用形态: + * - 标量比较:min(3, 1, 2) → 1 + * - 数组聚合:min(group.amounts) → 数组中的最小值 + * 单参数且为数组时按聚合处理,否则按逐参数比较。 + */ + min: (args) => { + const nums = collectNumbers(args) + return nums.length > 0 ? Math.min(...nums) : null + }, + + /** 最大值。调用形态同 min。 */ + max: (args) => { + const nums = collectNumbers(args) + return nums.length > 0 ? Math.max(...nums) : null + }, + + len: (args) => { + const value = args[0] + if (typeof value === "string") return value.length + if (Array.isArray(value)) return value.length + if (value === null || value === undefined) return 0 + return toString(value).length + }, + + upper: (args) => toString(args[0]).toUpperCase(), + lower: (args) => toString(args[0]).toLowerCase(), + trim: (args) => toString(args[0]).trim(), + + /** 截取子串:substr(text, start, length?)。start 从 0 开始。 */ + substr: (args) => { + const text = toString(args[0]) + const start = Math.max(0, Math.trunc(toNumber(args[1]))) + if (args.length < 3) return text.slice(start) + const length = Math.max(0, Math.trunc(toNumber(args[2]))) + return text.slice(start, start + length) + }, + + /** + * 替换:replace(text, search, replacement)。 + * + * 只做字面量替换,不接受正则,避免 ReDoS 与转义歧义。 + */ + replace: (args) => { + const text = toString(args[0]) + const search = toString(args[1]) + const replacement = toString(args[2]) + if (search === "") return text + return text.split(search).join(replacement) + }, + + isnull: (args) => args[0] === null || args[0] === undefined || args[0] === "", + + /** 返回第一个非空的参数。 */ + coalesce: (args) => { + for (const arg of args) { + if (arg !== null && arg !== undefined && arg !== "") return arg + } + return null + }, + + /** 转数字,失败返回 null 而不是 NaN,便于报表侧用 coalesce 兜底。 */ + number: (args) => { + const value = toNumber(args[0]) + return Number.isFinite(value) ? value : null + }, + + string: (args) => toString(args[0]), + + /** + * 构造日期:date(value)。 + * + * 返回 ISO 字符串而非 Date 对象,保证表达式结果始终是可序列化的纯数据。 + */ + date: (args) => { + const date = toDate(args[0]) + return date ? date.toISOString() : null + }, + + // ------------------------------------------------------------------------- + // 聚合函数 + // + // 聚合的语义约定(与分组配合使用): + // - 参数是数组时,直接对数组元素聚合,如 sum(group.rows.amount) 不成立, + // 应写 sum(group.amounts)。 + // - 参数是标量时,视为"单元素聚合",等价于对该值本身取聚合。 + // - 空数组/无有效值:sum 返回 0,avg/min/max 返回 null(避免除零与伪值), + // count 返回 0。 + // 这样设计的原因是:报表里合计为 0 与"没有数据"是两种不同语义, + // 前者应显示 0,后者应显示空白。 + // ------------------------------------------------------------------------- + + /** 求和。非数字元素被忽略。 */ + sum: (args) => { + const list = toAggregateList(args[0]) + let total = 0 + for (const item of list) { + const n = toNumber(item) + if (Number.isFinite(n)) total += n + } + return total + }, + + /** + * 平均值。无有效值时返回 null。 + * + * 空值(null/undefined)与空字符串会被完全忽略,不参与分子也不参与分母。 + * 这一点与 sum 不同:sum 把 null 视为 0 是正确的(合计里空值就是没贡献), + * 但 avg 若把 null 当 0 计入分母,会把平均值稀释成偏小的错误数字。 + */ + avg: (args) => { + const list = toAggregateList(args[0]) + let total = 0 + let count = 0 + for (const item of list) { + if (item === null || item === undefined) continue + if (typeof item === "string" && item.trim() === "") continue + const n = toNumber(item) + if (Number.isFinite(n)) { + total += n + count += 1 + } + } + return count > 0 ? total / count : null + }, + + /** 计数。无参数时统计元素个数;有参数时统计非空值个数。 */ + count: (args) => { + if (args.length === 0) return 0 + const list = toAggregateList(args[0]) + // 标量参数(非数组)视为 1 个元素 + if (!Array.isArray(args[0])) { + const value = args[0] + return value === null || value === undefined ? 0 : 1 + } + // 单参数形式 count(list):统计非空元素 + if (args.length === 1) { + let n = 0 + for (const item of list) { + if (item !== null && item !== undefined) n += 1 + } + return n + } + return list.length + }, +} + +/** + * 把聚合参数规范化为数组。 + * + * 数组原样返回;标量包装成单元素数组;null/undefined 返回空数组。 + */ +function toAggregateList(value: unknown): unknown[] { + if (Array.isArray(value)) return value + if (value === null || value === undefined) return [] + return [value] +} + +/** + * 从实参列表收集有限数字,兼容两种调用形态: + * - 多参数:min(3, 1, 2) —— 逐个转换 + * - 单参数数组:min(group.amounts) —— 展开数组转换 + */ +function collectNumbers(args: unknown[]): number[] { + const out: number[] = [] + // 单参数且为数组时按聚合处理 + const source = args.length === 1 ? toAggregateList(args[0]) : args + for (const item of source) { + const n = toNumber(item) + if (Number.isFinite(n)) out.push(n) + } + return out +} + +/** 判断函数名是否为内置函数。 */ +export function isBuiltinFunction(name: string): boolean { + return Object.prototype.hasOwnProperty.call(BUILTINS, name) +} + +/** 全部内置函数名。 */ +export function builtinFunctionNames(): string[] { + return Object.keys(BUILTINS).sort() +} diff --git a/code/one-designer/packages/report-expression/src/evaluator.ts b/code/one-designer/packages/report-expression/src/evaluator.ts new file mode 100644 index 00000000..5820c002 --- /dev/null +++ b/code/one-designer/packages/report-expression/src/evaluator.ts @@ -0,0 +1,373 @@ +/** + * 受限求值器(对应设计文档 §10、§4.2)。 + * + * 明确禁止通过 eval 或 new Function 执行任意 JavaScript。 + * + * 本文件是"受限"这一约束的落地点,安全边界如下: + * 1. 只处理 ast.ts 中定义的节点类型,没有其它可执行形式。 + * 2. 作用域根只能命中 scope.ts 的白名单,拿不到 window / globalThis / process。 + * 3. 属性访问拒绝 __proto__ / constructor / prototype,阻断原型链逃逸。 + * 4. 函数调用只查 builtins 表,且 parser 已在解析期做过白名单过滤。 + * 5. 求值过程中任何异常都被捕获并转成诊断,不会向上抛。 + */ +import type { Diagnostic } from "@report/core" +import { DIAGNOSTIC_CODES } from "@report/core" +import type { ExpressionNode } from "./ast.js" +import { printNode } from "./ast.js" +import { BUILTINS } from "./builtins.js" +import { parse } from "./parser.js" +import { resolveRoot, safeGet, type EvalScope } from "./scope.js" + +/** 求值选项。 */ +export interface EvalOptions { + /** 作用域。 */ + scope: EvalScope + /** 出错的元素 ID,用于诊断定位。 */ + elementId?: string + /** 字段路径,用于诊断定位。 */ + path?: string + /** + * 表达式执行的时间上限(毫秒)。 + * + * 由于语法受限、无循环结构,表达式天然会终止;该选项仅作为 + * 宿主对异常输入的最后一道保护,默认不启用。 + */ + timeoutMs?: number +} + +/** 求值结果。 */ +export interface EvalResult { + value: unknown + diagnostics: Diagnostic[] +} + +/** + * 求值已解析的 AST。 + * + * 不使用 eval / new Function,纯粹按节点类型递归求值。 + */ +export function evaluateAst(node: ExpressionNode, options: EvalOptions): EvalResult { + const diagnostics: Diagnostic[] = [] + + const context = { + scope: options.scope, + ...(options.elementId !== undefined ? { elementId: options.elementId } :{}), + ...(options.path !== undefined ? { path: options.path } :{}), + report(message: string, span?: { start: number; end: number }): void { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.EXPRESSION_EVAL_ERROR, + message, + ...(options.elementId !== undefined ? { elementId: options.elementId } :{}), + ...(options.path !== undefined ? { path: options.path } :{}), + }) + void span + }, + } + + const value = evalNode(node, context) + return { value, diagnostics } +} + +/** 内部求值上下文。 */ +interface EvalContext { + scope: EvalScope + elementId?: string + path?: string + report(message: string, span?: { start: number; end: number }): void +} + +function evalNode(node: ExpressionNode, ctx: EvalContext): unknown { + switch (node.type) { + case "Literal": + return node.value + + case "Identifier": + return resolveRoot(ctx.scope, node.name) + + case "Member": { + const object = evalNode(node.object, ctx) + return safeGet(object, node.property) + } + + case "Index": { + const object = evalNode(node.object, ctx) + const index = evalNode(node.index, ctx) + return safeGet(object, String(index)) + } + + case "Unary": { + const value = evalNode(node.argument, ctx) + switch (node.operator) { + case "-": { + const n = toNumberStrict(value) + return n === null ? null : -n + } + case "+": { + const n = toNumberStrict(value) + return n === null ? 0 : n + } + case "!": + return !toBoolean(value) + } + return null + } + + case "Binary": + return evalBinary(node, ctx) + + case "Logical": { + const left = evalNode(node.left, ctx) + switch (node.operator) { + case "&&": + // 短路:左值为假就不再求值右侧,避免无谓的错误诊断 + return toBoolean(left) ? evalNode(node.right, ctx) : left + case "||": + return toBoolean(left) ? left : evalNode(node.right, ctx) + case "??": + return left === null || left === undefined ? evalNode(node.right, ctx) : left + } + return null + } + + case "Conditional": + return toBoolean(evalNode(node.test, ctx)) + ? evalNode(node.consequent, ctx) + : evalNode(node.alternate, ctx) + + case "Call": + return evalCall(node, ctx) + } +} + +function evalBinary( + node: Extract, + ctx: EvalContext, +): unknown { + const left = evalNode(node.left, ctx) + const right = evalNode(node.right, ctx) + + switch (node.operator) { + case "+": { + // 一方是字符串则做拼接,否则做加法;null/undefined 视为 0 + if (typeof left === "string" || typeof right === "string") { + return toText(left) + toText(right) + } + // null / undefined 视为 0,与 toNumberStrict("") === 0 保持一致, + // 避免报表里出现空单元格导致整列求和变空。 + const a = left === null || left === undefined ? 0 : toNumberStrict(left) + const b = right === null || right === undefined ? 0 : toNumberStrict(right) + if (a === null || b === null) return null + return a + b + } + + case "-": + case "*": + case "/": + case "%": { + // 减法/乘法中 null 同样视为 0;除法与取模的除数仍按 0 处理并报诊断 + const a = left === null || left === undefined ? 0 : toNumberStrict(left) + const b = right === null || right === undefined ? 0 : toNumberStrict(right) + if (a === null || b === null) return null + if ((node.operator === "/" || node.operator === "%") && b === 0) { + // 除零返回 null 并给出诊断,避免产生 Infinity 污染报表 + ctx.report("除数为 0,结果按空值处理", node.span) + return null + } + switch (node.operator) { + case "-": return a - b + case "*": return a * b + case "/": return a / b + default: return a % b + } + } + + case "==": + case "===": + return looseEquals(left, right, node.operator === "===") + + case "!=": + case "!==": + return !looseEquals(left, right, node.operator === "!==") + + case ">": + case ">=": + case "<": + case "<=": + return compare(left, right, node.operator) + } +} + +function evalCall( + node: Extract, + ctx: EvalContext, +): unknown { + const fn = BUILTINS[node.callee] + if (!fn) { + // 正常情况下 parser 已拦截,这里作为纵深防御 + ctx.report(`不允许调用函数 "${node.callee}"`, node.span) + return null + } + + // if 惰性求值:只求值命中的分支,避免未选中分支的错误影响结果 + if (node.callee === "if") { + const test = evalNode(node.args[0]!, ctx) + return toBoolean(test) + ? node.args[1] + ? evalNode(node.args[1], ctx) + : null + : node.args[2] + ? evalNode(node.args[2], ctx) + : null + } + + const args = node.args.map((arg) => evalNode(arg, ctx)) + try { + return fn(args) + } catch (cause) { + ctx.report(`函数 ${node.callee} 执行失败:${(cause as Error).message}`, node.span) + return null + } +} + +function toBoolean(value: unknown): boolean { + if (value === null || value === undefined) return false + if (typeof value === "boolean") return value + if (typeof value === "number") return value !== 0 && !Number.isNaN(value) + if (typeof value === "string") return value.length > 0 + if (Array.isArray(value)) return value.length > 0 + return true +} + +function toText(value: unknown): string { + if (value === null || value === undefined) return "" + if (typeof value === "string") return value + if (typeof value === "number" || typeof value === "boolean") return String(value) + return "" +} + +/** 严格转数字,失败返回 null(而不是 NaN)。 */ +function toNumberStrict(value: unknown): number | null { + if (typeof value === "number") return Number.isNaN(value) ? null : value + if (typeof value === "boolean") return value ? 1 : 0 + if (typeof value === "string") { + const trimmed = value.trim() + if (trimmed === "") return 0 + const parsed = Number(trimmed) + return Number.isNaN(parsed) ? null : parsed + } + if (value === null || value === undefined) return null + return null +} + +function looseEquals(a: unknown, b: unknown, strict: boolean): boolean { + if (a === b) return true + if (a === null || a === undefined) return b === null || b === undefined + if (b === null || b === undefined) return false + if (strict) return false + + // 宽松比较:数值优先,其次字符串 + const na = toNumberStrict(a) + const nb = toNumberStrict(b) + if (na !== null && nb !== null) return na === nb + return toText(a) === toText(b) +} + +function compare(a: unknown, b: unknown, operator: string): boolean { + // 两边都能转数字时按数值比较,否则按字符串比较(支持日期串) + const na = toNumberStrict(a) + const nb = toNumberStrict(b) + const bothNumeric = na !== null && nb !== null + + let result: number + if (bothNumeric) { + result = na - nb + } else { + const sa = toText(a) + const sb = toText(b) + result = sa < sb ? -1 : sa > sb ? 1 : 0 + } + + switch (operator) { + case ">": return result > 0 + case ">=": return result >= 0 + case "<": return result < 0 + default: return result <= 0 + } +} + +/** + * 一步到位:解析并求值表达式字符串。 + * + * 单个字段出错不应导致整个文档无结果(§10),因此这里总是返回结果对象, + * 错误体现在 diagnostics 中。 + */ +export function evaluateExpression(source: string, options: EvalOptions): EvalResult { + const { ast, error } = parse(source) + if (!ast || error) { + return { + value: null, + diagnostics: [ + { + severity: "warning", + code: DIAGNOSTIC_CODES.EXPRESSION_PARSE_ERROR, + message: `表达式解析失败:${error?.message ?? "未知错误"}(${source})`, + ...(options.elementId !== undefined ? { elementId: options.elementId } :{}), + ...(options.path !== undefined ? { path: options.path } :{}), + }, + ], + } + } + return evaluateAst(ast, options) +} + +/** + * 预编译表达式。 + * + * Designer 里同一表达式会被反复求值(每一行一次),预编译避免重复解析。 + */ +export function compile(source: string): CompiledExpression { + const { ast, error } = parse(source) + return { + source, + ast, + error: error ? error.message : null, + } +} + +/** 已编译表达式。 */ +export interface CompiledExpression { + source: string + ast: ExpressionNode | null + error: string | null +} + +/** 求值已编译表达式。 */ +export function evaluateCompiled( + compiled: CompiledExpression, + options: EvalOptions, +): EvalResult { + if (!compiled.ast) { + return { + value: null, + diagnostics: [ + { + severity: "warning", + code: DIAGNOSTIC_CODES.EXPRESSION_PARSE_ERROR, + message: `表达式解析失败:${compiled.error ?? "未知错误"}(${compiled.source})`, + ...(options.elementId !== undefined ? { elementId: options.elementId } :{}), + ...(options.path !== undefined ? { path: options.path } :{}), + }, + ], + } + } + return evaluateAst(compiled.ast, options) +} + +/** 便捷函数:只关心值,忽略诊断。 */ +export function evaluateValue(source: string, scope: EvalScope): unknown { + return evaluateExpression(source, { scope }).value +} + +/** 打印 AST 回源码,便于诊断展示。 */ +export { printNode } diff --git a/code/one-designer/packages/report-expression/src/index.ts b/code/one-designer/packages/report-expression/src/index.ts new file mode 100644 index 00000000..47d9127d --- /dev/null +++ b/code/one-designer/packages/report-expression/src/index.ts @@ -0,0 +1,72 @@ +/** + * @report/expression 公共入口(对应设计文档 §4.2、§10)。 + * + * 表达式系统使用受限语法和 AST,不执行任意 JavaScript。 + * 本包只依赖 @report/core 的类型,不依赖 DOM 或任何求值框架。 + */ + +// 词法 +export type { Token, TokenType, TokenizeError, TokenizeResult } from "./tokenizer.js" +export { tokenize, KEYWORDS } from "./tokenizer.js" + +// AST +export type { + ExpressionNode, + NodeType, + SourceSpan, + LiteralNode, + IdentifierNode, + MemberNode, + IndexNode, + CallNode, + UnaryNode, + BinaryNode, + LogicalNode, + ConditionalNode, + ParseError, + ParseResult, +} from "./ast.js" +export { + walkNode, + collectRootIdentifiers, + collectFunctionNames, + printNode, +} from "./ast.js" + +// 语法 +export { parse, isValidExpression, allowedFunctionNames } from "./parser.js" + +// 作用域 +export type { EvalScope, ScopeRoot } from "./scope.js" +export { SCOPE_ROOTS, resolveRoot, safeGet } from "./scope.js" + +// 内置函数 +export type { BuiltinFunction } from "./builtins.js" +export { BUILTINS, isBuiltinFunction, builtinFunctionNames } from "./builtins.js" + +// 求值 +export type { + EvalOptions, + EvalResult, + CompiledExpression, +} from "./evaluator.js" +export { + evaluateAst, + evaluateExpression, + evaluateCompiled, + evaluateValue, + compile, +} from "./evaluator.js" + +// 绑定 +export type { + ResolveBindingOptions, + ResolveBindingResult, +} from "./binding.js" +export { + ExpressionCache, + resolveBinding, + formatValue, + fieldToExpression, + isBindingEmpty, +} from "./binding.js" diff --git a/code/one-designer/packages/report-expression/src/parser.ts b/code/one-designer/packages/report-expression/src/parser.ts new file mode 100644 index 00000000..80e8b339 --- /dev/null +++ b/code/one-designer/packages/report-expression/src/parser.ts @@ -0,0 +1,396 @@ +/** + * 表达式解析器(对应设计文档 §10)。 + * + * 手写递归下降解析器,优先级由低到高: + * + * conditional ?: + * logical-or || + * logical-and && + * nullish ?? + * equality == != === !== + * relational > >= < <= + * additive + - + * multiplicative * / % + * unary - + ! + * postfix . [] () + * primary 字面量 / 标识符 / ( expr ) + * + * 不使用 eval / new Function,也不生成可执行 JS 字符串。 + */ +import type { ExpressionNode, ParseError, ParseResult, SourceSpan } from "./ast.js" +import { tokenize, type Token } from "./tokenizer.js" + +/** 二元运算符优先级表。 */ +const BINARY_PRECEDENCE: Record = { + "||": 1, + "&&": 2, + "??": 3, + "==": 4, + "!=": 4, + "===": 4, + "!==": 4, + ">": 5, + ">=": 5, + "<": 5, + "<=": 5, + "+": 6, + "-": 6, + "*": 7, + "/": 7, + "%": 7, +} + +class Parser { + private readonly tokens: Token[] + private index = 0 + + constructor(tokens: Token[]) { + this.tokens = tokens + } + + private peek(): Token { + return this.tokens[this.index]! + } + + private next(): Token { + const token = this.tokens[this.index]! + if (token.type !== "eof") this.index += 1 + return token + } + + private span(from: Token, to: Token): SourceSpan { + return { start: from.start, end: to.end } + } + + /** 解析完整表达式,要求消费掉所有 token。 */ + parseExpression(): ParseResult { + try { + const expr = this.parseConditional() + const rest = this.peek() + if (rest.type !== "eof") { + return { + ast: null, + error: { + message: `表达式末尾存在多余内容 "${rest.value}"`, + span: { start: rest.start, end: rest.end }, + }, + } + } + return { ast: expr, error: null } + } catch (cause) { + if (cause instanceof ParseFailure) { + return { ast: null, error: { message: cause.message, span: cause.span } } + } + throw cause + } + } + + private fail(message: string, span: SourceSpan): never { + throw new ParseFailure(message, span) + } + + private parseConditional(): ExpressionNode { + const test = this.parseBinary(0) + const token = this.peek() + if (token.type === "operator" && token.value === "?") { + this.next() + const consequent = this.parseConditional() + const colon = this.peek() + if (!(colon.type === "punctuation" && colon.value === ":")) { + this.fail('三元表达式缺少 ":"', { start: colon.start, end: colon.end }) + } + this.next() + const alternate = this.parseConditional() + return { + type: "Conditional", + test, + consequent, + alternate, + span: this.span(token, colon), + } + } + return test + } + + /** 优先级爬升法解析二元与逻辑运算。 */ + private parseBinary(minPrecedence: number): ExpressionNode { + let left = this.parseUnary() + + for (;;) { + const token = this.peek() + if (token.type !== "operator") break + const precedence = BINARY_PRECEDENCE[token.value] + if (precedence === undefined || precedence < minPrecedence) break + + this.next() + // 左结合:右侧要求更高优先级 + const right = this.parseBinary(precedence + 1) + + if (token.value === "&&" || token.value === "||" || token.value === "??") { + left = { + type: "Logical", + operator: token.value, + left, + right, + span: { start: left.span.start, end: right.span.end }, + } + } else { + left = { + type: "Binary", + operator: token.value as never, + left, + right, + span: { start: left.span.start, end: right.span.end }, + } + } + } + + return left + } + + private parseUnary(): ExpressionNode { + const token = this.peek() + if (token.type === "operator" && (token.value === "-" || token.value === "+" || token.value === "!")) { + this.next() + const argument = this.parseUnary() + return { + type: "Unary", + operator: token.value as "-" | "+" | "!", + argument, + span: { start: token.start, end: argument.span.end }, + } + } + return this.parsePostfix() + } + + /** 后缀:成员访问、下标、函数调用。 */ + private parsePostfix(): ExpressionNode { + let node = this.parsePrimary() + + for (;;) { + const token = this.peek() + + if (token.type === "punctuation" && token.value === ".") { + this.next() + const prop = this.peek() + if (prop.type !== "identifier") { + this.fail("成员访问的右侧必须是标识符", { start: prop.start, end: prop.end }) + } + this.next() + node = { + type: "Member", + object: node, + property: prop.value, + span: { start: node.span.start, end: prop.end }, + } + continue + } + + if (token.type === "punctuation" && token.value === "[") { + this.next() + const index = this.parseConditional() + const close = this.peek() + if (!(close.type === "punctuation" && close.value === "]")) { + this.fail('下标访问缺少 "]"', { start: close.start, end: close.end }) + } + this.next() + node = { + type: "Index", + object: node, + index, + span: { start: node.span.start, end: close.end }, + } + continue + } + + // 调用只允许直接调用具名函数:foo(...),不支持 obj.method(...) + if (token.type === "punctuation" && token.value === "(" && node.type === "Identifier") { + this.next() + const args: ExpressionNode[] = [] + if (!(this.peek().type === "punctuation" && this.peek().value === ")")) { + for (;;) { + args.push(this.parseConditional()) + const sep = this.peek() + if (sep.type === "punctuation" && sep.value === ",") { + this.next() + continue + } + break + } + } + const close = this.peek() + if (!(close.type === "punctuation" && close.value === ")")) { + this.fail('函数调用缺少 ")"', { start: close.start, end: close.end }) + } + this.next() + + // 非白名单函数在解析期就拒绝,避免运行期才暴露 + if (!isAllowedFunctionName(node.name)) { + this.fail(`不允许调用函数 "${node.name}"`, node.span) + } + + node = { + type: "Call", + callee: node.name, + args, + span: { start: node.span.start, end: close.end }, + } + continue + } + + break + } + + return node + } + + private parsePrimary(): ExpressionNode { + const token = this.peek() + + switch (token.type) { + case "number": + this.next() + return { + type: "Literal", + value: token.numberValue!, + span: { start: token.start, end: token.end }, + } + + case "string": + this.next() + return { + type: "Literal", + value: token.stringValue!, + span: { start: token.start, end: token.end }, + } + + case "boolean": + this.next() + return { + type: "Literal", + value: token.value === "true", + span: { start: token.start, end: token.end }, + } + + case "null": + this.next() + return { + type: "Literal", + value: null, + span: { start: token.start, end: token.end }, + } + + case "identifier": + this.next() + if (token.value === "undefined") { + return { + type: "Literal", + value: null, + span: { start: token.start, end: token.end }, + } + } + return { + type: "Identifier", + name: token.value, + span: { start: token.start, end: token.end }, + } + + case "punctuation": + if (token.value === "(") { + this.next() + const inner = this.parseConditional() + const close = this.peek() + if (!(close.type === "punctuation" && close.value === ")")) { + this.fail('括号未闭合', { start: close.start, end: close.end }) + } + this.next() + return inner + } + break + + case "eof": + this.fail("表达式意外结束", { start: token.start, end: token.end }) + break + } + + this.fail(`意外的 token "${token.value}"`, { start: token.start, end: token.end }) + } +} + +class ParseFailure extends Error { + readonly span: SourceSpan + constructor(message: string, span: SourceSpan) { + super(message) + this.name = "ParseFailure" + this.span = span + } +} + +/** + * 允许调用的函数白名单。 + * + * 与 builtins 的实现保持同步;解析期即拒绝未登记函数, + * 使非法表达式在任何求值之前就失败。 + */ +const ALLOWED_FUNCTIONS = new Set([ + "if", + "and", + "or", + "not", + "concat", + "format", + "round", + "floor", + "ceil", + "abs", + "min", + "max", + "len", + "upper", + "lower", + "trim", + "substr", + "replace", + "isnull", + "coalesce", + "number", + "string", + "date", + // 聚合函数(与分组配合使用) + "sum", + "avg", + "count", +]) + +function isAllowedFunctionName(name: string): boolean { + return ALLOWED_FUNCTIONS.has(name) +} + +/** 列出全部受支持的函数名。 */ +export function allowedFunctionNames(): string[] { + return [...ALLOWED_FUNCTIONS].sort() +} + +/** + * 解析表达式为 AST。 + * + * 词法或语法错误时返回 error,不抛异常。 + */ +export function parse(source: string): ParseResult { + const { tokens, error } = tokenize(source) + if (error) { + return { + ast: null, + error: { message: error.message, span: { start: error.start, end: error.end } }, + } + } + if (tokens.length === 1 && tokens[0]!.type === "eof") { + return { ast: null, error: { message: "表达式为空", span: { start: 0, end: 0 } } } + } + return new Parser(tokens).parseExpression() +} + +/** 判断表达式是否可解析。 */ +export function isValidExpression(source: string): boolean { + return parse(source).ast !== null +} diff --git a/code/one-designer/packages/report-expression/src/scope.ts b/code/one-designer/packages/report-expression/src/scope.ts new file mode 100644 index 00000000..124c4aec --- /dev/null +++ b/code/one-designer/packages/report-expression/src/scope.ts @@ -0,0 +1,101 @@ +/** + * 表达式求值作用域(对应设计文档 §10)。 + * + * 作用域根由宿主注入,表达式只能访问这里显式提供的值。 + * 未提供的根名会解析为 undefined 并产生诊断,而不是去查全局对象, + * 因此 `window`、`globalThis`、`process` 这类标识符天然拿不到任何东西。 + */ + +/** 求值作用域。 */ +export interface EvalScope { + /** 当前行数据,对应表达式里的 row。 */ + row?: Record + /** 报表参数,对应 params。 */ + params?: Record + /** 分组值,对应 group。 */ + group?: Record + /** 页面信息,对应 page。 */ + page?: Record + /** 当前行索引(从 0 开始),对应 index。 */ + index?: number + /** 当前行序号(从 1 开始),对应 rownumber。 */ + rowNumber?: number + /** 数据集聚合,对应 dataset。 */ + dataset?: Record + /** 自定义根,供宿主扩展。 */ + extra?: Record +} + +/** 允许作为表达式根名的标识符。 */ +export const SCOPE_ROOTS = [ + "row", + "params", + "group", + "page", + "index", + "rownumber", + "dataset", +] as const + +export type ScopeRoot = (typeof SCOPE_ROOTS)[number] + +/** + * 取作用域根的值。 + * + * 明确只认识白名单根名,其余一律返回 undefined。 + * 这是"受限求值"的第一道闸门。 + */ +export function resolveRoot(scope: EvalScope, name: string): unknown { + switch (name) { + case "row": + return scope.row + case "params": + return scope.params + case "group": + return scope.group + case "page": + return scope.page + case "index": + return scope.index + case "rownumber": + return scope.rowNumber + case "dataset": + return scope.dataset + default: + // 宿主自定义根只允许命中 extra 中真实存在的键 + if (scope.extra && Object.prototype.hasOwnProperty.call(scope.extra, name)) { + return scope.extra[name] + } + return undefined + } +} + +/** + * 安全地读取对象属性。 + * + * 拒绝原型链上的危险键,避免 `row.constructor` / `row.__proto__` + * 这类写法拿到构造函数进而逃逸。 + */ +const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]) + +export function safeGet(target: unknown, key: string): unknown { + if (FORBIDDEN_KEYS.has(key)) return undefined + if (target === null || target === undefined) return undefined + if (typeof target !== "object" && typeof target !== "string") return undefined + + if (typeof target === "string") { + // 字符串只允许按索引取字符,不允许方法调用 + if (key === "length") return target.length + return undefined + } + if (Array.isArray(target)) { + if (key === "length") return target.length + const index = Number(key) + if (Number.isInteger(index) && index >= 0 && index < target.length) { + return target[index] + } + return undefined + } + if (!Object.prototype.hasOwnProperty.call(target, key)) return undefined + return (target as Record)[key] +} diff --git a/code/one-designer/packages/report-expression/src/tokenizer.ts b/code/one-designer/packages/report-expression/src/tokenizer.ts new file mode 100644 index 00000000..f5fd942c --- /dev/null +++ b/code/one-designer/packages/report-expression/src/tokenizer.ts @@ -0,0 +1,292 @@ +/** + * 表达式词法单元(对应设计文档 §10)。 + * + * 表达式系统使用受限语法和 AST,不执行任意 JavaScript。 + * 词法层只产出可枚举的 token 类型,任何超出语法范围的字符都直接报错, + * 而不是"忽略后继续",从而杜绝绕过语法检查的可能。 + */ + +export type TokenType = + | "number" + | "string" + | "boolean" + | "null" + | "identifier" + | "operator" + | "punctuation" + | "eof" + +export interface Token { + type: TokenType + /** 原始字面量文本。 */ + value: string + /** 数字 token 的解析结果。 */ + numberValue?: number + /** 字符串 token 的解析结果(已处理转义)。 */ + stringValue?: string + /** 在源串中的起始偏移。 */ + start: number + /** 在源串中的结束偏移(不含)。 */ + end: number + /** 从 0 开始的行号,用于诊断定位。 */ + line: number + /** 从 0 开始的列号。 */ + column: number +} + +/** 词法错误。 */ +export interface TokenizeError { + message: string + start: number + end: number + line: number + column: number +} + +/** 词法分析结果。 */ +export interface TokenizeResult { + tokens: Token[] + error: TokenizeError | null +} + +/** 运算符,按长度降序排列以保证最长匹配(如 >= 优先于 >)。 */ +const OPERATORS = [ + "===", + "!==", + "==", + "!=", + ">=", + "<=", + "&&", + "||", + "??", + "?", + "+", + "-", + "*", + "/", + "%", + ">", + "<", + "!", +] as const + +const PUNCTUATION = ["(", ")", ",", ".", "[", "]", ":"] as const + +/** 保留字:不可作为标识符使用。 */ +export const KEYWORDS = new Set(["true", "false", "null", "undefined", "if", "and", "or", "not"]) + +function isDigit(ch: string): boolean { + return ch >= "0" && ch <= "9" +} + +function isIdentStart(ch: string): boolean { + return ( + (ch >= "a" && ch <= "z") || + (ch >= "A" && ch <= "Z") || + ch === "_" || + ch === "$" || + // 允许中文标识符,便于 params.客户名称 这类写法 + ch.charCodeAt(0) > 0x7f + ) +} + +function isIdentPart(ch: string): boolean { + return isIdentStart(ch) || isDigit(ch) +} + +function isSpace(ch: string): boolean { + return ch === " " || ch === "\t" || ch === "\r" || ch === "\n" +} + +/** + * 词法分析。 + * + * 遇到非法字符立即返回错误,不产出"尽力而为"的 token 流。 + */ +export function tokenize(source: string): TokenizeResult { + const tokens: Token[] = [] + let i = 0 + let line = 0 + let lineStart = 0 + + const position = (index: number): { line: number; column: number } => ({ + line, + column: index - lineStart, + }) + + const fail = (message: string, start: number, end: number): TokenizeResult => { + const pos = position(start) + return { + tokens: [], + error: { message, start, end, line: pos.line, column: pos.column }, + } + } + + while (i < source.length) { + const ch = source[i]! + + if (isSpace(ch)) { + if (ch === "\n") { + line += 1 + lineStart = i + 1 + } + i += 1 + continue + } + + const start = i + const pos = position(start) + + // 数字:123、1.5、.5 不支持(必须写 0.5),避免与成员访问歧义 + if (isDigit(ch)) { + let j = i + while (j < source.length && isDigit(source[j]!)) j += 1 + if (source[j] === ".") { + j += 1 + if (!isDigit(source[j] ?? "")) { + return fail("小数点后必须有数字", start, j) + } + while (j < source.length && isDigit(source[j]!)) j += 1 + } + // 科学计数法 + if (source[j] === "e" || source[j] === "E") { + let k = j + 1 + if (source[k] === "+" || source[k] === "-") k += 1 + if (isDigit(source[k] ?? "")) { + while (k < source.length && isDigit(source[k]!)) k += 1 + j = k + } + } + const raw = source.slice(i, j) + tokens.push({ + type: "number", + value: raw, + numberValue: Number(raw), + start, + end: j, + line: pos.line, + column: pos.column, + }) + i = j + continue + } + + // 字符串:单引号或双引号 + if (ch === '"' || ch === "'") { + const quote = ch + let j = i + 1 + let out = "" + let closed = false + while (j < source.length) { + const c = source[j]! + if (c === "\\") { + const next = source[j + 1] + if (next === undefined) return fail("字符串以转义符结尾", start, j + 1) + switch (next) { + case "n": out += "\n"; break + case "t": out += "\t"; break + case "r": out += "\r"; break + case "\\": out += "\\"; break + case '"': out += '"'; break + case "'": out += "'"; break + default: + // 未知转义一律原样保留反斜杠,避免静默吞掉字符 + out += next + break + } + j += 2 + continue + } + if (c === quote) { + closed = true + j += 1 + break + } + if (c === "\n") { + return fail("字符串字面量不能跨行", start, j) + } + out += c + j += 1 + } + if (!closed) return fail("字符串字面量未闭合", start, source.length) + tokens.push({ + type: "string", + value: source.slice(start, j), + stringValue: out, + start, + end: j, + line: pos.line, + column: pos.column, + }) + i = j + continue + } + + // 标识符 / 关键字 + if (isIdentStart(ch)) { + let j = i + while (j < source.length && isIdentPart(source[j]!)) j += 1 + const raw = source.slice(i, j) + let type: TokenType = "identifier" + if (raw === "true" || raw === "false") type = "boolean" + else if (raw === "null") type = "null" + tokens.push({ + type, + value: raw, + start, + end: j, + line: pos.line, + column: pos.column, + }) + i = j + continue + } + + // 运算符:最长匹配 + const operator = OPERATORS.find((op) => source.startsWith(op, i)) + if (operator) { + const j = i + operator.length + tokens.push({ + type: "operator", + value: operator, + start, + end: j, + line: pos.line, + column: pos.column, + }) + i = j + continue + } + + // 标点 + const punct = PUNCTUATION.find((p) => source.startsWith(p, i)) + if (punct) { + const j = i + punct.length + tokens.push({ + type: "punctuation", + value: punct, + start, + end: j, + line: pos.line, + column: pos.column, + }) + i = j + continue + } + + return fail(`无法识别的字符 "${ch}"`, start, start + 1) + } + + const endPos = position(source.length) + tokens.push({ + type: "eof", + value: "", + start: source.length, + end: source.length, + line: endPos.line, + column: endPos.column, + }) + + return { tokens, error: null } +} diff --git a/code/one-designer/packages/report-expression/tests/aggregate.test.ts b/code/one-designer/packages/report-expression/tests/aggregate.test.ts new file mode 100644 index 00000000..e3ad88a5 --- /dev/null +++ b/code/one-designer/packages/report-expression/tests/aggregate.test.ts @@ -0,0 +1,180 @@ +/** + * 聚合函数测试(对应设计文档 §16 阶段 3 的"跨页合计"能力基础)。 + * + * 语义约定: + * - 空数组:sum → 0,avg/min/max → null,count → 0 + * - "合计为 0" 与 "没有数据" 是两种不同语义,前者显示 0,后者显示空白 + */ +import { describe, expect, it } from "vitest" +import { evaluateValue } from "../src/evaluator.js" +import type { EvalScope } from "../src/scope.js" + +const scope: EvalScope = { + row: { + amounts: [10, 20, 30], + prices: [1.5, 2.5], + mixed: [1, "abc", 3, null], + empty: [], + names: ["甲", "乙", "丙"], + }, + params: {}, +} + +describe("sum", () => { + it("数组求和", () => { + expect(evaluateValue("sum(row.amounts)", scope)).toBe(60) + }) + + it("空数组返回 0(合计为零应显示 0)", () => { + expect(evaluateValue("sum(row.empty)", scope)).toBe(0) + }) + + it("忽略非数字元素", () => { + expect(evaluateValue("sum(row.mixed)", scope)).toBe(4) + }) + + it("标量视为单元素", () => { + expect(evaluateValue("sum(5)", scope)).toBe(5) + }) + + it("null 返回 0", () => { + expect(evaluateValue("sum(row.nothing)", scope)).toBe(0) + }) + + it("小数求和", () => { + expect(evaluateValue("sum(row.prices)", scope)).toBe(4) + }) + + it("可与其他运算组合", () => { + expect(evaluateValue("sum(row.amounts) * 2", scope)).toBe(120) + }) +}) + +describe("avg", () => { + it("平均值为数字", () => { + expect(evaluateValue("avg(row.amounts)", scope)).toBe(20) + }) + + it("空数组返回 null(无数据应显示空白)", () => { + expect(evaluateValue("avg(row.empty)", scope)).toBeNull() + }) + + it("忽略非数字元素后再平均", () => { + // 1 和 3 是有效数字,'abc' 与 null 被忽略 + expect(evaluateValue("avg(row.mixed)", scope)).toBe(2) + }) + + it("小数的平均值", () => { + expect(evaluateValue("avg(row.prices)", scope)).toBe(2) + }) +}) + +describe("count", () => { + it("统计元素个数", () => { + expect(evaluateValue("count(row.amounts)", scope)).toBe(3) + }) + + it("空数组返回 0", () => { + expect(evaluateValue("count(row.empty)", scope)).toBe(0) + }) + + it("统计非空元素个数", () => { + // mixed = [1, 'abc', 3, null],null 不计入 + expect(evaluateValue("count(row.mixed)", scope)).toBe(3) + }) + + it("无参数返回 0", () => { + expect(evaluateValue("count()", scope)).toBe(0) + }) + + it("标量计为 1", () => { + expect(evaluateValue("count(5)", scope)).toBe(1) + }) + + it("null 计为 0", () => { + expect(evaluateValue("count(row.nothing)", scope)).toBe(0) + }) +}) + +describe("min / max 双形态", () => { + it("多参数形态(标量比较)", () => { + expect(evaluateValue("min(3, 1, 2)", scope)).toBe(1) + expect(evaluateValue("max(3, 1, 2)", scope)).toBe(3) + }) + + it("单参数数组形态(聚合)", () => { + expect(evaluateValue("min(row.amounts)", scope)).toBe(10) + expect(evaluateValue("max(row.amounts)", scope)).toBe(30) + }) + + it("空数组返回 null", () => { + expect(evaluateValue("min(row.empty)", scope)).toBeNull() + expect(evaluateValue("max(row.empty)", scope)).toBeNull() + }) + + it("忽略非数字", () => { + expect(evaluateValue("max(row.mixed)", scope)).toBe(3) + }) + + it("单元素数组与标量结果一致", () => { + expect(evaluateValue("min(5)", scope)).toBe(5) + expect(evaluateValue("min(row.prices)", scope)).toBe(1.5) + }) +}) + +describe("聚合与分组作用域协作", () => { + it("可通过 group 作用域聚合", () => { + const groupScope: EvalScope = { + row: { name: "甲" }, + group: { + key: "华北", + amounts: [100, 200, 300], + count: 3, + }, + params: {}, + } + expect(evaluateValue("sum(group.amounts)", groupScope)).toBe(600) + expect(evaluateValue("avg(group.amounts)", groupScope)).toBe(200) + expect(evaluateValue("group.count", groupScope)).toBe(3) + }) + + it("可通过 dataset 作用域做全表合计", () => { + const datasetScope: EvalScope = { + row: {}, + dataset: { amounts: [1, 2, 3, 4], count: 4 }, + params: {}, + } + expect(evaluateValue("sum(dataset.amounts)", datasetScope)).toBe(10) + }) + + it("聚合结果可被格式化", () => { + const groupScope: EvalScope = { + row: {}, + group: { amounts: [1234.5, 2.25] }, + params: {}, + } + expect(evaluateValue("format(sum(group.amounts), '#,##0.00')", groupScope)).toBe("1,236.75") + }) + + it("空组不产生 NaN", () => { + const groupScope: EvalScope = { row: {}, group: { amounts: [] }, params: {} } + const sum = evaluateValue("sum(group.amounts)", groupScope) + expect(sum).toBe(0) + expect(Number.isNaN(sum as number)).toBe(false) + }) +}) + +describe("聚合函数仍在安全白名单内", () => { + it("sum/avg/count 可调用", () => { + expect(evaluateValue("sum(1)", scope)).toBe(1) + expect(evaluateValue("avg(1)", scope)).toBe(1) + expect(evaluateValue("count(1)", scope)).toBe(1) + }) + + it("未登记的聚合名仍被拒绝", () => { + // median 等未实现,应在解析期就被拒绝 + const scope2: EvalScope = { row: {} } + const result = evaluateValue("median(row.x)", scope2) + expect(result).toBeNull() + }) +}) diff --git a/code/one-designer/packages/report-expression/tests/expression.test.ts b/code/one-designer/packages/report-expression/tests/expression.test.ts new file mode 100644 index 00000000..0490eb80 --- /dev/null +++ b/code/one-designer/packages/report-expression/tests/expression.test.ts @@ -0,0 +1,456 @@ +/** + * 表达式安全性测试(对应 §4.2、§10、§14 的"表达式安全性测试")。 + * + * 这是本包最重要的测试:受限求值器必须无法执行任意 JavaScript, + * 也无法通过原型链或作用域外标识符逃逸。 + */ +import { describe, expect, it } from "vitest" +import { parse } from "../src/parser.js" +import { evaluateExpression, evaluateValue, compile, evaluateCompiled } from "../src/evaluator.js" +import { tokenize } from "../src/tokenizer.js" +import type { EvalScope } from "../src/scope.js" + +const scope: EvalScope = { + row: { amount: 100, price: 3, quantity: 4, name: "张三", empty: null }, + params: { tax: 0.1, customerName: "李四" }, + index: 0, + rowNumber: 1, +} + +describe("禁止任意代码执行(§4.2)", () => { + it("不允许调用未登记的函数", () => { + const result = parse("eval('1+1')") + expect(result.ast).toBeNull() + expect(result.error?.message).toContain("不允许调用函数") + }) + + it("eval 在解析期即被拒绝", () => { + expect(parse("eval(\"alert(1)\")").ast).toBeNull() + }) + + it("Function 构造器不可用", () => { + expect(parse("Function('return 1')()").ast).toBeNull() + }) + + it("setTimeout 等宿主 API 不可用", () => { + expect(parse("setTimeout('x', 1)").ast).toBeNull() + }) + + it("require / import 不可用", () => { + expect(parse("require('fs')").ast).toBeNull() + expect(parse("import('fs')").ast).toBeNull() + }) + + it("无法通过 constructor 逃逸", () => { + const value = evaluateValue("row.constructor", scope) + expect(value).toBeUndefined() + }) + + it("无法通过 __proto__ 逃逸", () => { + expect(evaluateValue("row.__proto__", scope)).toBeUndefined() + expect(evaluateValue("row['__proto__']", scope)).toBeUndefined() + }) + + it("无法通过 prototype 逃逸", () => { + expect(evaluateValue("row.prototype", scope)).toBeUndefined() + }) + + it("constructor.constructor 组合逃逸失败", () => { + expect(evaluateValue("row.constructor.constructor", scope)).toBeUndefined() + }) + + it("全局对象标识符解析为 undefined", () => { + for (const name of ["window", "globalThis", "process", "document", "global"]) { + expect(evaluateValue(name, scope)).toBeUndefined() + } + }) + + it("模板字符串语法不被支持", () => { + expect(parse("`${row.amount}`").ast).toBeNull() + }) + + it("箭头函数语法不被支持", () => { + expect(parse("(x) => x").ast).toBeNull() + }) + + it("赋值表达式不被支持", () => { + expect(parse("row.amount = 1").ast).toBeNull() + }) + + it("不支持语句分隔与多表达式", () => { + expect(parse("1; 2").ast).toBeNull() + }) + + it("不支持注释语法", () => { + expect(parse("1 // comment").ast).toBeNull() + }) + + it("不允许成员方法调用", () => { + // row.name.toUpperCase() 中 callee 不是简单标识符,解析失败 + expect(parse("row.name.toUpperCase()").ast).toBeNull() + }) + + it("字符串不提供任何方法", () => { + // "张三" 是两个字符 + expect(evaluateValue("row.name.length", scope)).toBe(2) + expect(evaluateValue("row.name.toUpperCase", scope)).toBeUndefined() + }) + + it("数组不提供任何方法", () => { + const s: EvalScope = { row: { list: [1, 2, 3] } } + expect(evaluateValue("row.list.length", s)).toBe(3) + expect(evaluateValue("row.list.map", s)).toBeUndefined() + }) + + it("new 关键字不被支持", () => { + expect(parse("new Date()").ast).toBeNull() + }) + + it("不支持 this", () => { + expect(evaluateValue("this", scope)).toBeUndefined() + }) +}) + +describe("作用域白名单", () => { + it("只认识约定的根名", () => { + expect(evaluateValue("row.amount", scope)).toBe(100) + expect(evaluateValue("params.tax", scope)).toBe(0.1) + expect(evaluateValue("index", scope)).toBe(0) + expect(evaluateValue("rownumber", scope)).toBe(1) + }) + + it("未知根名返回 undefined", () => { + expect(evaluateValue("nonexistent.field", scope)).toBeUndefined() + }) + + it("extra 中的自定义根可访问", () => { + const s: EvalScope = { extra: { custom: { x: 7 } } } + expect(evaluateValue("custom.x", s)).toBe(7) + }) + + it("extra 的 Object.prototype 属性不可访问", () => { + expect(evaluateValue("toString", { extra: {} })).toBeUndefined() + }) +}) + +describe("tokenizer", () => { + it("识别数字", () => { + const { tokens } = tokenize("1 + 2.5 + 1e3") + const nums = tokens.filter((t) => t.type === "number").map((t) => t.numberValue) + expect(nums).toEqual([1, 2.5, 1000]) + }) + + it("拒绝小数点后无数字", () => { + expect(tokenize("1.").error?.message).toContain("小数点后必须有数字") + }) + + it("识别字符串与转义", () => { + const { tokens } = tokenize("'a\\nb'") + expect(tokens[0]?.stringValue).toBe("a\nb") + }) + + it("拒绝未闭合字符串", () => { + expect(tokenize("'abc").error?.message).toContain("未闭合") + }) + + it("拒绝跨行字符串", () => { + expect(tokenize("'a\nb'").error?.message).toContain("不能跨行") + }) + + it("识别中文标识符", () => { + const { tokens } = tokenize("row.客户名称") + expect(tokens.some((t) => t.value === "客户名称")).toBe(true) + }) + + it("运算符最长匹配", () => { + const { tokens } = tokenize("a >= b") + expect(tokens.some((t) => t.value === ">=")).toBe(true) + }) + + it("拒绝非法字符", () => { + expect(tokenize("a # b").error?.message).toContain("无法识别的字符") + }) + + it("记录行列位置", () => { + const { tokens } = tokenize("a +\n b") + const b = tokens.find((t) => t.value === "b") + expect(b?.line).toBe(1) + expect(b?.column).toBe(2) + }) +}) + +describe("parser 结构与错误", () => { + it("拒绝空表达式", () => { + expect(parse("").error?.message).toContain("表达式为空") + expect(parse(" ").error?.message).toContain("表达式为空") + }) + + it("括号必须闭合", () => { + expect(parse("(1 + 2").error?.message).toContain("未闭合") + }) + + it("三元必须有冒号", () => { + expect(parse("1 ? 2").error?.message).toContain('缺少 ":"') + }) + + it("拒绝多余内容", () => { + expect(parse("1 2").error?.message).toContain("多余内容") + }) + + it("运算符优先级正确", () => { + expect(evaluateValue("1 + 2 * 3", scope)).toBe(7) + expect(evaluateValue("(1 + 2) * 3", scope)).toBe(9) + }) + + it("比较与逻辑优先级正确", () => { + expect(evaluateValue("1 + 1 == 2 && 3 > 1", scope)).toBe(true) + }) + + it("成员访问右侧必须是标识符", () => { + expect(parse("row.1").error?.message).toContain("必须是标识符") + }) + + it("下标必须闭合", () => { + expect(parse("row[0").error?.message).toContain('缺少 "]"') + }) +}) + +describe("运算语义", () => { + it("算术运算", () => { + expect(evaluateValue("row.price * row.quantity", scope)).toBe(12) + expect(evaluateValue("10 / 4", scope)).toBe(2.5) + expect(evaluateValue("10 % 3", scope)).toBe(1) + }) + + it("字符串拼接", () => { + expect(evaluateValue("'a' + 'b'", scope)).toBe("ab") + expect(evaluateValue("row.name + '先生'", scope)).toBe("张三先生") + }) + + it("数字与字符串相加按拼接处理", () => { + expect(evaluateValue("1 + '2'", scope)).toBe("12") + }) + + it("除零返回 null 并产生诊断", () => { + const result = evaluateExpression("1 / 0", { scope }) + expect(result.value).toBeNull() + expect(result.diagnostics.some((d) => d.message.includes("除数为 0"))).toBe(true) + }) + + it("null 参与算术视为 0", () => { + expect(evaluateValue("row.empty + 1", scope)).toBe(1) + }) + + it("空值不产生 NaN", () => { + const result = evaluateExpression("row.empty * 2", { scope }) + expect(Number.isNaN(result.value as number)).toBe(false) + }) + + it("一元运算", () => { + expect(evaluateValue("-5", scope)).toBe(-5) + expect(evaluateValue("!true", scope)).toBe(false) + expect(evaluateValue("!row.empty", scope)).toBe(true) + }) + + it("比较运算", () => { + expect(evaluateValue("2 > 1", scope)).toBe(true) + expect(evaluateValue("'a' < 'b'", scope)).toBe(true) + expect(evaluateValue("2 >= 2", scope)).toBe(true) + }) + + it("相等比较", () => { + expect(evaluateValue("1 == 1", scope)).toBe(true) + expect(evaluateValue("1 == '1'", scope)).toBe(true) + expect(evaluateValue("1 === '1'", scope)).toBe(false) + expect(evaluateValue("null == null", scope)).toBe(true) + }) + + it("逻辑运算短路", () => { + // 右侧会触发除零诊断;短路后不应产生该诊断 + const result = evaluateExpression("false && (1 / 0)", { scope }) + expect(result.diagnostics).toEqual([]) + }) + + it("?? 只在空值时取右侧", () => { + expect(evaluateValue("row.empty ?? 'fallback'", scope)).toBe("fallback") + expect(evaluateValue("row.amount ?? 0", scope)).toBe(100) + }) + + it("三元条件", () => { + expect(evaluateValue("row.amount > 0 ? '有' : '无'", scope)).toBe("有") + expect(evaluateValue("row.amount < 0 ? '有' : '无'", scope)).toBe("无") + }) + + it("嵌套成员访问", () => { + const s: EvalScope = { row: { a: { b: { c: 42 } } } } + expect(evaluateValue("row.a.b.c", s)).toBe(42) + }) + + it("下标访问数组", () => { + const s: EvalScope = { row: { list: [10, 20] } } + expect(evaluateValue("row.list[1]", s)).toBe(20) + expect(evaluateValue("row.list[9]", s)).toBeUndefined() + }) + + it("越界下标返回 undefined 而非报错", () => { + const result = evaluateExpression("row.list[9]", { scope: { row: { list: [1] } } }) + expect(result.diagnostics.filter((d) => d.severity === "error")).toEqual([]) + }) +}) + +describe("内置函数", () => { + it("if 惰性求值,不执行未命中分支", () => { + const result = evaluateExpression("if(true, 1, 1 / 0)", { scope }) + expect(result.value).toBe(1) + expect(result.diagnostics).toEqual([]) + }) + + it("if 命中 else 分支", () => { + expect(evaluateValue("if(false, 'a', 'b')", scope)).toBe("b") + }) + + it("and / or / not", () => { + expect(evaluateValue("and(true, true)", scope)).toBe(true) + expect(evaluateValue("and(true, false)", scope)).toBe(false) + expect(evaluateValue("or(false, true)", scope)).toBe(true) + expect(evaluateValue("not(false)", scope)).toBe(true) + }) + + it("concat", () => { + expect(evaluateValue("concat('a', 'b', 'c')", scope)).toBe("abc") + expect(evaluateValue("concat(row.empty, 'x')", scope)).toBe("x") + }) + + it("round / floor / ceil / abs", () => { + expect(evaluateValue("round(1.234, 2)", scope)).toBe(1.23) + expect(evaluateValue("round(1.5)", scope)).toBe(2) + expect(evaluateValue("floor(1.9)", scope)).toBe(1) + expect(evaluateValue("ceil(1.1)", scope)).toBe(2) + expect(evaluateValue("abs(-3)", scope)).toBe(3) + }) + + it("min / max 忽略非数字", () => { + expect(evaluateValue("min(3, 1, 2)", scope)).toBe(1) + expect(evaluateValue("max(3, 1, 2)", scope)).toBe(3) + expect(evaluateValue("min('a', 5)", scope)).toBe(5) + }) + + it("len / upper / lower / trim", () => { + expect(evaluateValue("len('abc')", scope)).toBe(3) + expect(evaluateValue("upper('ab')", scope)).toBe("AB") + expect(evaluateValue("lower('AB')", scope)).toBe("ab") + expect(evaluateValue("trim(' a ')", scope)).toBe("a") + }) + + it("substr", () => { + expect(evaluateValue("substr('abcdef', 2)", scope)).toBe("cdef") + expect(evaluateValue("substr('abcdef', 2, 3)", scope)).toBe("cde") + }) + + it("replace 只做字面量替换", () => { + expect(evaluateValue("replace('a.b.c', '.', '-')", scope)).toBe("a-b-c") + }) + + it("replace 不接受正则元字符作为模式", () => { + // 正则语义下 '.' 应匹配任意字符,这里必须只匹配字面点 + expect(evaluateValue("replace('abc', '.', 'X')", scope)).toBe("abc") + }) + + it("isnull / coalesce", () => { + expect(evaluateValue("isnull(row.empty)", scope)).toBe(true) + expect(evaluateValue("isnull(row.amount)", scope)).toBe(false) + expect(evaluateValue("coalesce(row.empty, '', 'x')", scope)).toBe("x") + expect(evaluateValue("coalesce(row.amount, 0)", scope)).toBe(100) + }) + + it("number 转失败返回 null 而非 NaN", () => { + expect(evaluateValue("number('12')", scope)).toBe(12) + expect(evaluateValue("number('abc')", scope)).toBeNull() + }) + + it("string 对象不隐式序列化", () => { + expect(evaluateValue("string(row)", scope)).toBe("") + }) + + it("format 日期", () => { + expect(evaluateValue("format('2024-03-05T08:09:10Z', 'YYYY-MM-DD')", scope)).toBe("2024-03-05") + }) + + it("format 数字千分位", () => { + expect(evaluateValue("format(1234567.891, '#,##0.00')", scope)).toBe("1,234,567.89") + }) + + it("format 百分比", () => { + expect(evaluateValue("format(0.1234, '0.0%')", scope)).toBe("12.3%") + }) + + it("format 负数千分位", () => { + expect(evaluateValue("format(-1234.5, '#,##0.00')", scope)).toBe("-1,234.50") + }) + + it("date 返回可序列化的 ISO 字符串", () => { + const value = evaluateValue("date('2024-03-05T08:09:10Z')", scope) + expect(typeof value).toBe("string") + expect(value).toBe("2024-03-05T08:09:10.000Z") + }) + + it("表达式结果永远是纯数据(不含 Date 对象)", () => { + const value = evaluateValue("date('2024-01-01')", scope) + expect(value instanceof Date).toBe(false) + }) +}) + +describe("错误处理(§10 单个字段错误不影响整体)", () => { + it("解析失败返回诊断而非抛异常", () => { + const result = evaluateExpression("1 +", { scope }) + expect(result.value).toBeNull() + expect(result.diagnostics).toHaveLength(1) + expect(result.diagnostics[0]?.code).toBe("expression.parse-error") + }) + + it("诊断携带元素 ID 与路径", () => { + const result = evaluateExpression("1 +", { + scope, + elementId: "el-1", + path: "/sections/1/children/0/binding/expression", + }) + expect(result.diagnostics[0]?.elementId).toBe("el-1") + expect(result.diagnostics[0]?.path).toBe("/sections/1/children/0/binding/expression") + }) + + it("函数内部异常被捕获", () => { + const result = evaluateExpression("round('abc')", { scope }) + expect(result.diagnostics.filter((d) => d.severity === "error")).toEqual([]) + }) + + it("诊断不包含原始表达式外的敏感内容", () => { + const result = evaluateExpression("nosuchfn(1)", { scope }) + expect(result.diagnostics[0]?.message).toBeTruthy() + }) +}) + +describe("编译与缓存", () => { + it("compile 返回可复用的 AST", () => { + const c = compile("row.amount + 1") + expect(c.ast).not.toBeNull() + expect(c.error).toBeNull() + }) + + it("compile 记录解析错误", () => { + const c = compile("1 +") + expect(c.ast).toBeNull() + expect(c.error).toBeTruthy() + }) + + it("evaluateCompiled 复用 AST", () => { + const c = compile("row.amount * 2") + expect(evaluateCompiled(c, { scope }).value).toBe(200) + expect(evaluateCompiled(c, { scope: { row: { amount: 5 } } }).value).toBe(10) + }) + + it("evaluateCompiled 对坏表达式返回诊断", () => { + const result = evaluateCompiled(compile("1 +"), { scope }) + expect(result.value).toBeNull() + expect(result.diagnostics).toHaveLength(1) + }) +}) diff --git a/code/one-designer/packages/report-expression/tsconfig.json b/code/one-designer/packages/report-expression/tsconfig.json new file mode 100644 index 00000000..ae429f46 --- /dev/null +++ b/code/one-designer/packages/report-expression/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/code/one-designer/packages/report-expression/tsconfig.test.json b/code/one-designer/packages/report-expression/tsconfig.test.json new file mode 100644 index 00000000..6cefc694 --- /dev/null +++ b/code/one-designer/packages/report-expression/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "*.config.ts"] +} diff --git a/code/one-designer/packages/report-expression/tsup.config.ts b/code/one-designer/packages/report-expression/tsup.config.ts new file mode 100644 index 00000000..be47bf93 --- /dev/null +++ b/code/one-designer/packages/report-expression/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + target: "es2022", +}) diff --git a/code/one-designer/packages/report-expression/vitest.config.ts b/code/one-designer/packages/report-expression/vitest.config.ts new file mode 100644 index 00000000..0af27743 --- /dev/null +++ b/code/one-designer/packages/report-expression/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + }, +}) diff --git a/code/one-designer/packages/report-layout/README.md b/code/one-designer/packages/report-layout/README.md new file mode 100644 index 00000000..061a818d --- /dev/null +++ b/code/one-designer/packages/report-layout/README.md @@ -0,0 +1,192 @@ +# @report/layout + +元素测量、文本换行、动态高度、明细表展开与分页,产出所有 Renderer 共用的 `LayoutDocument`。 + +> 对应设计文档 §4.3、§9、§3.3。 + +## 定位 + +Layout 负责**尺寸计算、文本测量、换行、表格展开、动态高度、分页**; +Renderer 只负责把 `LayoutDocument` 画成 HTML / SVG / PDF,**绝不重新计算分页**(§3.3)。 + +Layout 通过 `TextMeasurer`、`FontResolver` 接口获得环境能力,**不直接依赖 DOM**(§4.3)。 +因此同一套分页逻辑可以在浏览器、Node 和服务端复用。 + +## 安装与使用 + +```ts +import { createDocument } from "@report/core" +import { layoutDocument, createEstimateMeasurer, createPassthroughFontResolver } from "@report/layout" + +const doc = createDocument({ name: "销售单" }) + +const result = await layoutDocument( + doc, + { data: { ds1: rows }, parameters: { year: 2024 } }, + { + textMeasurer: createEstimateMeasurer(), + fontResolver: createPassthroughFontResolver(), + }, +) + +console.log(result.pageCount) +for (const page of result.pages) { + for (const node of page.nodes) { + console.log(node.id, node.type, node.frame) + } +} +``` + +### Node 侧使用真实字体度量 + +`@report/layout/fonts` 依赖 `node:fs`,因此单独入口,避免浏览器打包被迫引入 Node 内置模块。 + +```ts +import { FontLibrary, createFontMeasurer, findSystemFontSources } from "@report/layout/fonts" +import { layoutDocument } from "@report/layout" + +const library = new FontLibrary() +library.loadAll(findSystemFontSources()) + +const measurer = createFontMeasurer({ + library, + fallbackFamilies: ["SimSun", "SimHei", "Microsoft YaHei"], +}) + +const result = await layoutDocument(doc, context, { + textMeasurer: measurer, + fontResolver: measurer.resolver, +}) +``` + +## 核心概念 + +### LayoutDocument 是唯一的分页真相 + +```ts +interface LayoutDocument { + pages: LayoutPage[] + diagnostics: LayoutDiagnostic[] + pageCount: number +} + +interface LayoutPage { + width: number // mm + height: number // mm + pageNumber: number // 从 1 开始 + nodes: LayoutNode[] + sections: LayoutPageSection[] +} +``` + +所有节点使用**绝对坐标**(mm,原点在页面左上角),Renderer 不需要再做任何层级累加。 + +### 单位约定 + +文档内部统一 mm(§7)。`units.ts` 集中提供换算: + +| 函数 | 说明 | +| --- | --- | +| `mmToPx` / `pxToMm` | 与 Designer 显示层换算(1in = 96px) | +| `mmToPt` / `ptToMm` | 与 PDF 输出边界换算(1in = 72pt) | +| `fontSizeToMm` | 字号 pt → 几何 mm,测量时使用 | + +### 文本换行 + +- 西文按**词**断行,不拆开单词(超长词强制断开并标记 `brokeWords`)。 +- 中文按**字**断行,无需空格分词。 +- 支持**避头点**(`,。)】` 不出现在行首)与**避尾点**(`(【` 不出现在行尾)。 +- 显式换行符保留为硬换行。 + +换行结果完全由注入的 `TextMeasurer` 决定,因此与具体字体环境解耦。 + +### 动态高度 + +`text` 元素内容高于 `frame.height` 时,布局阶段会**自动增高**(§3.3)。 +这是表格 `rowHeightMode: "auto"` 的基础。 + +### 中英混排与字体回退 + +拉丁字体通常不含汉字。度量器按**字符**查找覆盖它的字体: + +``` +"中" 在 Arial 中 → glyphId 0(.notdef)→ 回退到 SimHei → 1.0em +"A" 在 Arial 中 → glyphId 36 → 直接用 Arial → 0.667em +``` + +**关键正确性细节**:字体缺失字符时返回 `.notdef` 字形,而它**本身带有 advance 宽度**。 +若直接返回该宽度,缺字会被静默当成正常字符排版。本实现对此显式返回 `null`, +由调用方按回退字体或兜底宽度处理,并在 `TextMetrics.fallback` 中标记。 + +### 分页 + +- 页眉 / 页脚每页重复,其高度先从可用高度扣除。 +- 明细表产出「表头块 + 每行一个块」的序列,因此**行与行之间可跨页断开,单行不会被拆散**。 +- `repeatHeaderOnEachPage` 控制表头是否每页重复。 +- 页眉 + 页脚高度达到或超过内容区时返回**明确错误诊断**而不是静默产出重叠页面。 +- 单个内容块高于整页时产生 `layout.page-overflow` 警告并让其独占一页(允许溢出)。 + +### 页脚页码占位符 + +页脚文本中的 `{pageNumber}` 与 `{pageCount}` 会在分页完成后替换为实际值, +用于「第 X 页 / 共 Y 页」。 + +## 目录结构 + +``` +src/ +├── units.ts 单位换算 +├── measurer.ts TextMeasurer / FontResolver 接口 + 估算度量器 +├── font-parser.ts TTF/OTF/TTC 字节解析(无文件系统依赖) +├── load-font.ts 字体加载与 FontLibrary(Node) +├── font-measurer.ts 基于真实字体度量的 TextMeasurer +├── system-fonts.ts 系统字体探测 +├── text-wrap.ts 分段、换行、避头尾、动态高度 +├── layout-document.ts LayoutDocument 模型 +├── measure.ts 样式解析、绑定求值、元素测量 +├── table.ts 明细表展开为分页块 +├── paginate.ts 分页引擎 +├── engine.ts Layout Engine 主入口 +├── index.ts 主入口(纯逻辑,无 node:fs) +└── fonts.ts 字体入口(Node) +``` + +## 字体解析说明 + +`font-parser.ts` 自行解析字体二进制,**不依赖 canvas 等原生模块**: + +| 表 | 用途 | +| --- | --- | +| `head` | unitsPerEm(度量归一化基准) | +| `hhea` | numberOfHMetrics、ascender、descender、lineGap | +| `hmtx` | 每个字形的 advanceWidth | +| `cmap` | 字符 → 字形映射(format 4 与 format 12) | +| `maxp` | numGlyphs | +| `name` | 字体族名(用于匹配与诊断) | + +同时支持 `.ttf` / `.otf` 与 `.ttc` 字体集合(按 `fontIndex` 取字体)。 + +## 开发 + +```bash +pnpm build # tsup 打包 +pnpm test # vitest +pnpm typecheck # tsc --noEmit +``` + +## 当前测试覆盖 + +| 测试文件 | 用例数 | 覆盖内容 | +| --- | --- | --- | +| `font.test.ts` | 29 | TTF/TTC 解析、真实字宽、中英回退、缺字检测 | +| `text-wrap.test.ts` | 30 | 分词、按词/按字换行、避头尾、动态高度 | +| `pagination.test.ts` | 28 | 页面几何、单页/多页、页眉页脚重复、Y 分层 | + +合计 **87** 个用例。字体相关用例在缺少系统字体的环境中自动跳过。 + +## 尚未实现 + +- 表达式驱动的分组(`group-header` / `group-footer`)与跨页合计。 +- 容器内嵌套表格的多级拆分。 +- 表格列自动宽度(当前按声明宽度 + 等比压缩)。 +- PDF 输出(属 `report-renderer`,阶段 4)。 diff --git a/code/one-designer/packages/report-layout/package.json b/code/one-designer/packages/report-layout/package.json new file mode 100644 index 00000000..0730df61 --- /dev/null +++ b/code/one-designer/packages/report-layout/package.json @@ -0,0 +1,38 @@ +{ + "name": "@report/layout", + "version": "0.1.0", + "description": "元素测量、文本换行、动态高度与分页,产出统一的 LayoutDocument", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./fonts": { + "types": "./dist/fonts.d.ts", + "import": "./dist/fonts.js", + "require": "./dist/fonts.cjs" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit -p tsconfig.test.json" + }, + "dependencies": { + "@report/core": "workspace:*", + "@report/expression": "workspace:*" + }, + "devDependencies": { + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/code/one-designer/packages/report-layout/src/context.ts b/code/one-designer/packages/report-layout/src/context.ts new file mode 100644 index 00000000..69046c23 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/context.ts @@ -0,0 +1,142 @@ +/** + * 运行时数据与布局选项(对应设计文档 §8、§9)。 + * + * 报表文档只保存数据源定义,不保存连接、Token 和本次查询结果。 + * 运行时数据统一通过 RenderContext 注入,数据获取通过 DataProvider 注入。 + */ +import type { + DataProvider, + DatasetDefinition, + Diagnostic, + ReportDocument, + RenderContext, +} from "@report/core" +import { DIAGNOSTIC_CODES } from "@report/core" +import type { FontResolver, TextMeasurer } from "./measurer.js" + +// RenderContext 与 DataProvider 定义在 core(§8),这里只做转发, +// 保证 Designer 与 Layout 使用的是同一份契约。 +export type { RenderContext, DataProvider } + +/** + * 布局选项(§9)。 + * + * textMeasurer / fontResolver 在文档里是必填的。这里放宽为可选是为了支持 + * "无字体环境仍能产出结构正确的 LayoutDocument"(如无字体的 CI): + * 缺省时回退到估算度量器,并产生一条明确诊断。 + * 生产环境应当注入真实度量器,否则分页结果与真实排版存在偏差。 + */ +export interface LayoutOptions { + /** 数据提供器。当 RenderContext.data 缺少某数据集时,用它按需取数。 */ + dataProvider?: DataProvider + /** 文本度量器。缺省时回退到估算度量器并产生诊断。 */ + textMeasurer?: TextMeasurer + /** 字体解析器。缺省时使用直通实现。 */ + fontResolver?: FontResolver + /** + * 文本溢出的容差(mm)。默认 0.01。 + */ + overflowTolerance?: number +} + +/** 创建空的渲染上下文。 */ +export function createRenderContext( + partial: Partial = {}, +): RenderContext { + return { + data: partial.data ?? {}, + parameters: partial.parameters ?? {}, + ...(partial.locale !== undefined ? { locale: partial.locale } :{}), + ...(partial.timezone !== undefined ? { timezone: partial.timezone } :{}), + } +} + +/** + * 解析数据集数据。 + * + * 优先使用 RenderContext 中已注入的数据;缺失时才调用 DataProvider。 + * 两者都没有时返回空数组并产生诊断,而不是抛异常—— + * 数据缺失不应阻止报表渲染(§13)。 + */ +export async function resolveDataset( + dataset: DatasetDefinition, + context: RenderContext, + dataProvider: DataProvider | undefined, + diagnostics: Diagnostic[], +): Promise>> { + const injected = context.data[dataset.id] + if (injected) return injected + + if (dataProvider) { + try { + return await dataProvider.getDataset(dataset, context) + } catch (cause) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.DATA_DATASET_MISSING, + message: `数据集 "${dataset.id}" 取数失败:${(cause as Error).message}`, + }) + return [] + } + } + + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.DATA_DATASET_MISSING, + message: `数据集 "${dataset.id}" 没有可用数据:RenderContext 未注入,且未提供 DataProvider`, + }) + return [] +} + +/** 预先解析文档中所有需要的数据集。 */ +export async function resolveAllDatasets( + document: ReportDocument, + context: RenderContext, + dataProvider: DataProvider | undefined, + diagnostics: Diagnostic[], +): Promise>>> { + const result = new Map>>() + const needed = collectRequiredDatasetIds(document) + + for (const datasetId of needed) { + const dataset = document.datasets[datasetId] + if (!dataset) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.REF_DANGLING_DATASET, + message: `文档引用了不存在的数据集 "${datasetId}"`, + }) + result.set(datasetId, []) + continue + } + result.set(datasetId, await resolveDataset(dataset, context, dataProvider, diagnostics)) + } + return result +} + +/** 收集文档中实际会被用到的数据集 ID(区域与明细表)。 */ +export function collectRequiredDatasetIds(document: ReportDocument): Set { + const ids = new Set() + + const walk = (elements: readonly import("@report/core").ReportElement[]): void => { + for (const element of elements) { + if (element.type === "table") { + const datasetId = (element as { datasetId?: string }).datasetId + if (datasetId) ids.add(datasetId) + const columns = (element as { + columns?: { children?: import("@report/core").ReportElement[] }[] + }).columns + for (const column of columns ?? []) { + if (column.children?.length) walk(column.children) + } + } + if (element.children?.length) walk(element.children) + } + } + + for (const section of document.sections) { + if (section.datasetId) ids.add(section.datasetId) + walk(section.children) + } + return ids +} diff --git a/code/one-designer/packages/report-layout/src/engine.ts b/code/one-designer/packages/report-layout/src/engine.ts new file mode 100644 index 00000000..34e87b20 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/engine.ts @@ -0,0 +1,405 @@ +/** + * Layout Engine 主入口(对应设计文档 §9)。 + * + * LayoutEngine.layout(document, context, options) → LayoutDocument + * + * 完整流程: + * 1. 解析运行时数据(RenderContext / DataProvider) + * 2. 逐区域测量(样式解析、绑定求值、文本换行、动态高度) + * 3. 分页(页眉页脚重复、明细表按行拆分) + * 4. 产出统一的 LayoutDocument + 诊断 + * + * 关键约束:本流程不依赖 DOM,也不重新访问网络—— + * 数据来自注入,字体能力来自 TextMeasurer。 + */ +import type { + Diagnostic, + ReportDocument, + ReportSection, +} from "@report/core" +import { DIAGNOSTIC_CODES, isGroupSectionKind, isV1SectionKind } from "@report/core" +import type { EvalScope } from "@report/expression" +import type { FontResolver, TextMeasurer } from "./measurer.js" +import { + createEstimateMeasurer, + createPassthroughFontResolver, +} from "./measurer.js" +import type { RenderContext, LayoutOptions } from "./context.js" +import { + collectRequiredDatasetIds, + resolveAllDatasets, +} from "./context.js" +import type { MeasuredElement } from "./measure.js" +import { + createMeasureContext, + contentHeight, + measureSection, +} from "./measure.js" +import type { LayoutBlock, MeasuredSection } from "./paginate.js" +import { + computePageGeometry, + paginate, + toLayoutNode, + toMeasuredSection, +} from "./paginate.js" +import { buildTableBlocks } from "./table.js" +import type { GroupLevelSpec } from "./group.js" +import { buildGroupBlocks, collectGroupLevels, computeGroups } from "./group.js" +import type { LayoutDocument, LayoutNode } from "./layout-document.js" + +/** + * Layout Engine 契约(§9)。 + * + * 定义在本包而非 core:分页是 layout 的职责,core 不依赖具体布局实现。 + */ +export interface LayoutEngine { + layout( + document: ReportDocument, + context: RenderContext, + options?: LayoutOptions, + ): Promise +} + +/** Layout Engine 实现。 */ +export class DefaultLayoutEngine implements LayoutEngine { + async layout( + document: ReportDocument, + context: RenderContext, + options?: LayoutOptions, + ): Promise { + return layoutDocument(document, context, options) + } +} + +/** 创建 Layout Engine。 */ +export function createLayoutEngine(): LayoutEngine { + return new DefaultLayoutEngine() +} + +/** + * 执行一次完整布局。 + * + * options 省略时使用估算度量器,并产生字体回退诊断—— + * 这保证在没有任何字体能力的环境(如无字体的 CI)里仍能产出结构正确的 + * LayoutDocument,只是文本宽度为估算值。 + */ +export async function layoutDocument( + document: ReportDocument, + context: RenderContext = { data: {}, parameters: {} }, + options?: LayoutOptions, +): Promise { + const diagnostics: Diagnostic[] = [] + + const measurer: TextMeasurer = options?.textMeasurer ?? createEstimateMeasurer() + const fontResolver: FontResolver = options?.fontResolver ?? createPassthroughFontResolver() + + if (!options?.textMeasurer) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.LAYOUT_TEXT_OVERFLOW, + message: + "未提供 TextMeasurer,已回退到估算度量器;文本宽度为估算值," + + "分页结果可能与真实排版存在偏差", + }) + } + + // 1. 解析数据 + const datasets = await resolveAllDatasets( + document, + context, + options?.dataProvider, + diagnostics, + ) + const dataContext: RenderContext = { ...context, data: context.data } + + // 2. 测量各区域 + const geometry = computePageGeometry(document) + const ctx = createMeasureContext({ + document, + renderContext: dataContext, + measurer, + fontResolver, + }) + if (options?.overflowTolerance !== undefined) { + ctx.overflowTolerance = options.overflowTolerance + } + + const measuredSections: MeasuredSection[] = [] + + // 分组级别定义(group-header / group-footer) + const groupLevels = collectGroupLevels(document.sections, diagnostics) + + for (let i = 0; i < document.sections.length; i += 1) { + const section = document.sections[i]! + const path = `/sections/${i}` + const options_ = section.options ?? {} + + // 分组区域由明细区的分组流程消费,不单独成块 + if (isGroupSectionKind(section.kind)) { + continue + } + + if (!isV1SectionKind(section.kind)) { + diagnostics.push({ + severity: "info", + code: DIAGNOSTIC_CODES.ELEMENT_UNKNOWN_TYPE, + message: `区域 "${section.kind}" 不在 V1 渲染范围内,已跳过`, + path: `${path}/kind`, + }) + continue + } + + // 明细表需要按数据集展开成多行,走单独的块构建流程 + if (section.kind === "detail") { + const built = buildDetailSection( + section, + i, + document, + ctx, + datasets, + geometry.contentWidth, + path, + diagnostics, + groupLevels, + ) + measuredSections.push(built) + continue + } + + const scope: EvalScope = { + params: context.parameters, + page: { pageNumber: 1, pageCount: 1 }, + } + const elements = measureSection(section, geometry.contentWidth, ctx, scope, path) + for (const element of elements) diagnostics.push(...element.diagnostics) + + measuredSections.push( + toMeasuredSection({ + sectionId: section.id, + kind: section.kind, + elements, + // 页眉页脚每页重复 + repeatOnEachPage: options_.repeatOnEachPage ?? true, + allowSplit: options_.allowSplit ?? false, + }), + ) + } + + // 3. 分页 + const result = paginate(measuredSections, geometry, { + document, + diagnostics, + allowSplit: true, + }) + + // 4. 回填总页数,便于页脚渲染"第 X 页 / 共 Y 页" + annotatePageCount(result) + + return result +} + +/** 在每页上记录页码信息,供页脚使用。 */ +function annotatePageCount(document: LayoutDocument): void { + for (const page of document.pages) { + for (const node of page.nodes) { + annotateNode(node, page.pageNumber, document.pageCount) + } + } +} + +function annotateNode( + node: import("./layout-document.js").LayoutNode, + pageNumber: number, + pageCount: number, +): void { + if (node.content?.kind === "text") { + // 页脚里常见的 {pageNumber} / {pageCount} 占位符在此替换 + const content = node.content + if (content.lines.some((line) => line.includes("{pageNumber}") || line.includes("{pageCount}"))) { + content.lines = content.lines.map((line) => + line + .replace(/\{pageNumber\}/g, String(pageNumber)) + .replace(/\{pageCount\}/g, String(pageCount)), + ) + content.text = content.lines.join("\n") + } + } + for (const child of node.children ?? []) { + annotateNode(child, pageNumber, pageCount) + } +} + +/** + * 构建明细区域。 + * + * 明细表按数据集展开成表头块 + 每行一个块,从而支持跨页拆分; + * 非表格元素作为普通块处理。 + */ +function buildDetailSection( + section: ReportSection, + sectionIndex: number, + document: ReportDocument, + ctx: ReturnType, + datasets: Map>>, + contentWidth: number, + path: string, + diagnostics: Diagnostic[], + groupLevels: readonly GroupLevelSpec[] = [], +): MeasuredSection { + const tableBlocks: LayoutBlock[] = [] + const staticElements: MeasuredElement[] = [] + let staticHeight = 0 + + for (let i = 0; i < section.children.length; i += 1) { + const element = section.children[i]! + const elementPath = `${path}/children/${i}` + + if (element.type === "table") { + const blocks = buildTableBlocks({ + element, + ctx, + datasets, + parentWidth: contentWidth, + path: elementPath, + diagnostics, + }) + tableBlocks.push(...blocks) + continue + } + + // 非表格元素:在明细区顶部依次排布 + const measured = measureSection( + { ...section, children: [element] }, + contentWidth, + ctx, + { params: {}, page: {} }, + path, + ) + for (const m of measured) { + // 累加垂直偏移,避免多个静态元素重叠 + m.frame.y += staticHeight + staticHeight += m.frame.height + staticElements.push(m) + diagnostics.push(...m.diagnostics) + } + } + + // 静态元素作为首个块,表格块依次跟随 + const blocks: LayoutBlock[] = [] + if (staticElements.length > 0) { + const height = contentHeight(staticElements) + blocks.push({ + height, + render: (top) => staticElements.map((element) => toNode(element, top)), + }) + } + + // 有分组定义时,详细行按组包裹:组头 → 行 → 组脚 + if (groupLevels.length > 0 && tableBlocks.length > 0) { + const groupSource = resolveGroupSource(section, groupLevels) + if (!groupSource) { + blocks.push(...tableBlocks) + } else { + const rows = datasets.get(groupSource.datasetId) ?? [] + if (rows.length === 0) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.DATA_DATASET_MISSING, + message: `分组所依据的数据集 "${groupSource.datasetId}" 没有数据,分组区域将不输出`, + path: `${path}/children/${groupSource.tableIndex}`, + }) + blocks.push(...tableBlocks) + } else { + const tree = computeGroups(rows, groupLevels, diagnostics) + // 表头 / 空数据占位块与数据行块分开: + // 行块带 rowIndex,按行身份选取,不依赖数组下标(避免混入非行块导致错位)。 + // 表头判定用 kind === "header":表头始终存在, + // 只是可能不重复(repeatOnEachPage 为 false)。 + const headerBlocks = tableBlocks.filter((b) => b.kind === "header") + const nonRowBlocks = tableBlocks.filter( + (b) => b.kind !== "header" && b.rowIndex === undefined, + ) + const rowBlocks = tableBlocks.filter((b) => b.rowIndex !== undefined) + + // 表头在最前,空数据占位紧随其后 + if (headerBlocks.length > 0) blocks.push(headerBlocks[0]!) + blocks.push(...nonRowBlocks) + + if (rowBlocks.length === 0) { + // 表格没有产出任何行块(例如无 columns),保持原样输出 + blocks.push(...headerBlocks.slice(1)) + } else { + const grouped = buildGroupBlocks({ + groups: tree.groups, + levels: groupLevels, + ctx, + parentWidth: contentWidth, + sectionPath: path, + diagnostics, + allRows: rows, + // 按数据行索引区间选取块,而不是按下标切片 + buildDetailBlocks: (start, end) => + rowBlocks.filter( + (b) => b.rowIndex !== undefined && b.rowIndex >= start && b.rowIndex < end, + ), + }) + blocks.push(...grouped) + } + } + } + } else { + blocks.push(...tableBlocks) + } + + if (blocks.length === 0) { + return toMeasuredSection({ + sectionId: section.id, + kind: section.kind, + elements: [], + repeatOnEachPage: false, + allowSplit: true, + }) + } + + return { + sectionId: section.id, + kind: section.kind, + elements: staticElements, + height: blocks.reduce((sum, block) => sum + block.height, 0), + repeatOnEachPage: false, + allowSplit: true, + blocks, + } +} + +function toNode(element: MeasuredElement, top: number): LayoutNode { + // 复用 paginate 的转换逻辑,保持坐标处理一致 + return toLayoutNode(element, top) +} + +/** + * 找出分组所依据的数据集。 + * + * 规则:取明细区中第一个带 datasetId 的表格元素。 + * 分组表达式在**该数据集的行**上求值,因此必须先确定数据源。 + * 找不到可用的表格时返回 null,调用方回退为不分组输出。 + */ +function resolveGroupSource( + section: ReportSection, + groupLevels: readonly GroupLevelSpec[], +): { datasetId: string; tableIndex: number } | null { + if (groupLevels.length === 0) return null + + // 区域自身声明了数据集时优先使用 + if (section.datasetId) { + return { datasetId: section.datasetId, tableIndex: -1 } + } + + for (let i = 0; i < section.children.length; i += 1) { + const element = section.children[i]! + if (element.type !== "table") continue + const datasetId = (element as { datasetId?: string }).datasetId + if (datasetId) return { datasetId, tableIndex: i } + } + return null +} diff --git a/code/one-designer/packages/report-layout/src/font-measurer.ts b/code/one-designer/packages/report-layout/src/font-measurer.ts new file mode 100644 index 00000000..5d832517 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/font-measurer.ts @@ -0,0 +1,147 @@ +/** + * 基于真实字体度量的文本度量器(对应设计文档 §9)。 + * + * 与 estimate 版度量器的区别:宽度来自字体文件的 advance 表, + * 因此中英混排、比例字体的换行结果与真实排版一致。 + * + * 中英混排策略:按字符逐个查找覆盖它的字体。 + * 拉丁字体通常不含汉字,需要回退到 CJK 字体——这正是 + * "中文字体、长文本"测试(§14)关注的核心场景。 + */ +import type { ResolvedStyle } from "@report/core" +import type { TextMeasurer, TextMetrics, FontResolver, ResolvedFont } from "./measurer.js" +import type { FontLibrary } from "./load-font.js" +import type { ParsedFont } from "./font-parser.js" +import { ptToMm } from "./units.js" + +/** 字体回退事件,供诊断使用。 */ +export interface FontFallbackEvent { + requested: string + resolved: string + codePoint: number + char: string +} + +/** 度量选项。 */ +export interface FontMeasurerOptions { + library: FontLibrary + /** + * 回退字体族列表,按优先级排列。 + * + * 当首选字体不覆盖某字符时按此顺序查找。 + */ + fallbackFamilies?: readonly string[] + /** 当字符在所有字体中都缺失时使用的兜底宽度(em)。默认 0.5。 */ + missingGlyphWidth?: number + /** 回退事件回调,用于收集诊断。 */ + onFallback?: (event: FontFallbackEvent) => void +} + +/** + * 创建基于字体库的度量器。 + * + * 度量单位为 mm,与文档模型保持一致(§7)。 + */ +export function createFontMeasurer(options: FontMeasurerOptions): TextMeasurer & { + resolver: FontResolver +} { + const { library } = options + const fallbackFamilies = options.fallbackFamilies ?? [] + const missingGlyphWidth = options.missingGlyphWidth ?? 0.5 + + const resolveFont = (fontFamily: string): { font: ParsedFont | undefined; family: string; fallback: boolean } => { + const hit = library.resolveStack(fontFamily) + if (hit) return { font: hit.font, family: hit.family, fallback: false } + + // 首选未命中,按回退列表找 + for (const name of fallbackFamilies) { + const font = library.get(name) + if (font) return { font, family: name, fallback: true } + } + // 再退一步:任意可用字体 + const first = library.families()[0] + if (first) { + const font = library.get(first) + if (font) return { font, family: first, fallback: true } + } + return { font: undefined, family: fontFamily, fallback: true } + } + + /** 计算单个字符的宽度(em)。缺失时返回 null。 */ + const charAdvance = ( + codePoint: number, + ch: string, + primary: ParsedFont | undefined, + preferredFamily: string, + ): { advance: number; font: ParsedFont | undefined } | null => { + if (primary?.hasGlyph(codePoint)) { + const adv = primary.charAdvance(codePoint) + if (adv !== null) return { advance: adv, font: primary } + } + + // 主字体不覆盖,查找可回退的字体 + const fallback = library.findForCodePoint(codePoint, fallbackFamilies) + if (fallback) { + const adv = fallback.charAdvance(codePoint) + if (adv !== null) { + options.onFallback?.({ + requested: preferredFamily, + resolved: fallback.metrics.familyName, + codePoint, + char: ch, + }) + return { advance: adv, font: fallback } + } + } + return null + } + + const measurer: TextMeasurer & { resolver: FontResolver } = { + resolver: { + resolve(fontFamily: string): ResolvedFont { + const { family, fallback } = resolveFont(fontFamily) + return { requested: fontFamily, resolved: family, fallback } + }, + }, + + measure(text: string, style: ResolvedStyle): TextMetrics { + const emMm = ptToMm(style.fontSize) + const { font, family, fallback: fontFallback } = resolveFont(style.fontFamily) + + let totalEm = 0 + let usedFallback = fontFallback + let missing = 0 + + for (const ch of text) { + const codePoint = ch.codePointAt(0)! + const hit = charAdvance(codePoint, ch, font, family) + if (hit) { + totalEm += hit.advance + if (hit.font && hit.font !== font) usedFallback = true + } else { + // 所有字体都不覆盖:按兜底宽度计入并标记,避免静默低估宽度 + totalEm += missingGlyphWidth + missing += 1 + usedFallback = true + } + } + + // 字重加粗会导致宽度增加;这里给出保守的 2% 补偿 + if (style.fontWeight === "bold" || (typeof style.fontWeight === "number" && style.fontWeight >= 600)) { + totalEm *= 1.02 + } + + const height = emMm * style.lineHeight + return { + width: totalEm * emMm, + height, + ascent: emMm * 0.8, + descent: emMm * 0.2, + fontFamily: family, + fallback: usedFallback || missing > 0, + } + }, + } + + return measurer +} diff --git a/code/one-designer/packages/report-layout/src/font-parser.ts b/code/one-designer/packages/report-layout/src/font-parser.ts new file mode 100644 index 00000000..86465e81 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/font-parser.ts @@ -0,0 +1,233 @@ +/** + * TrueType / OpenType 字体度量解析(对应设计文档 §9)。 + * + * 目的:让 Node / 服务端也具备真实文本度量能力,而不必依赖 canvas 等 + * 原生模块。只解析度量所需的表,不涉及字形轮廓渲染。 + * + * 解析的表: + * - head:unitsPerEm、indexToLocFormat(本实现不需要 loca,但用于确认字体有效) + * - hhea:numberOfHMetrics、ascender、descender、lineGap + * - hmtx:每个字形的 advanceWidth + * - cmap:字符 → 字形映射(优先 format 4,其次 format 12) + * - maxp:numGlyphs + * - name:字体族名(用于 FontResolver 匹配与诊断) + * + * 本模块不依赖 DOM,也不依赖任何原生扩展。 + */ + +/** 解析错误。 */ +export class FontParseError extends Error { + constructor(message: string) { + super(message) + this.name = "FontParseError" + } +} + +/** 字体表目录项。 */ +interface TableRecord { + offset: number + length: number +} + +/** cmap 子表。 */ +interface CmapSubtable { + format: number + offset: number +} + +/** 已解析的字体度量。 */ +export interface FontMetrics { + /** 字体族名,如 "Arial"、"SimHei"。 */ + familyName: string + /** 全名,如 "SimHei Regular"。 */ + fullName: string + /** em 单位数,典型 1000 或 2048。 */ + unitsPerEm: number + /** 字形总数。 */ + numGlyphs: number + /** 整数度量的字形数。 */ + numberOfHMetrics: number + /** 上升部(em 单位)。 */ + ascender: number + /** 下降部(em 单位,通常为负)。 */ + descender: number + /** 行间距(em 单位)。 */ + lineGap: number + /** 源文件路径(若从文件加载)。 */ + source?: string +} + +/** 可查询度量与字形映射的字体对象。 */ +export class ParsedFont { + readonly metrics: FontMetrics + private readonly buffer: Buffer + private readonly base: number + private readonly tables: Map + private readonly cmapFormat4: number + private readonly cmapFormat12: number + + /** 字形宽度缓存:gid → advance(em 单位)。 */ + private readonly advanceCache = new Map() + /** 字符宽度缓存:码点 → advance(em 单位)。 */ + private readonly charCache = new Map() + + constructor(buffer: Buffer, base: number, metrics: FontMetrics, tables: Map) { + this.buffer = buffer + this.base = base + this.metrics = metrics + this.tables = tables + + const cmap = tables.get("cmap") + if (!cmap) throw new FontParseError("字体缺少 cmap 表") + const subtableCount = buffer.readUInt16BE(cmap.offset + 2) + let format4 = -1 + let format12 = -1 + for (let i = 0; i < subtableCount; i += 1) { + const record = cmap.offset + 4 + i * 8 + const platformId = buffer.readUInt16BE(record) + const encodingId = buffer.readUInt16BE(record + 2) + const subtableOffset = cmap.offset + buffer.readUInt32BE(record + 4) + const format = buffer.readUInt16BE(subtableOffset) + + // 优先 Unicode 平台(0)与 Windows BMP(3,1);format 12 覆盖增补平面 + if (format === 12 && (platformId === 0 || platformId === 3)) { + if (format12 < 0) format12 = subtableOffset + } else if (format === 4 && (platformId === 0 || (platformId === 3 && encodingId === 1))) { + if (format4 < 0) format4 = subtableOffset + } + } + if (format4 < 0 && format12 < 0) { + throw new FontParseError("字体缺少受支持的 cmap 子表(format 4 / 12)") + } + this.cmapFormat4 = format4 + this.cmapFormat12 = format12 + } + + /** 查询码点对应的字形 ID;0 表示 .notdef(未覆盖)。 */ + glyphId(codePoint: number): number { + // 增补平面优先用 format 12 + if (codePoint > 0xffff && this.cmapFormat12 >= 0) { + return this.lookupFormat12(codePoint) + } + if (this.cmapFormat4 >= 0) { + const gid = this.lookupFormat4(codePoint) + if (gid !== 0) return gid + } + if (this.cmapFormat12 >= 0) return this.lookupFormat12(codePoint) + return 0 + } + + /** 判断字体是否覆盖某码点。 */ + hasGlyph(codePoint: number): boolean { + if (codePoint === 0x20) return true + return this.glyphId(codePoint) !== 0 + } + + /** 字形 advance 宽度(em 单位)。 */ + advanceOfGlyph(glyphId: number): number { + const cached = this.advanceCache.get(glyphId) + if (cached !== undefined) return cached + + const hmtx = this.tables.get("hmtx") + if (!hmtx) throw new FontParseError("字体缺少 hmtx 表") + + // 超出 numberOfHMetrics 的字形复用最后一个 advance + const index = Math.min(glyphId, this.metrics.numberOfHMetrics - 1) + const offset = hmtx.offset + index * 4 + if (offset + 2 > this.buffer.length) { + this.advanceCache.set(glyphId, 0) + return 0 + } + const advance = this.buffer.readUInt16BE(offset) / this.metrics.unitsPerEm + this.advanceCache.set(glyphId, advance) + return advance + } + + /** + * 字符宽度(em 单位)。 + * + * 未覆盖的字符返回 null,由调用方决定回退策略—— + * 这一点很关键:.notdef 字形本身带有 advance 宽度, + * 若直接返回该宽度会把"缺字"静默当成正常字符渲染。 + */ + charAdvance(codePoint: number): number | null { + const cached = this.charCache.get(codePoint) + if (cached !== undefined) return cached + + const gid = this.glyphId(codePoint) + if (gid === 0 && codePoint !== 0x20 && codePoint !== 0) return null + + const advance = this.advanceOfGlyph(gid) + this.charCache.set(codePoint, advance) + return advance + } + + private lookupFormat4(codePoint: number): number { + if (codePoint > 0xffff) return 0 + const t = this.cmapFormat4 + const segCountX2 = this.buffer.readUInt16BE(t + 6) + const segCount = segCountX2 / 2 + + const endOffset = t + 14 + const startOffset = endOffset + segCountX2 + 2 + const deltaOffset = startOffset + segCountX2 + const rangeOffsetBase = deltaOffset + segCountX2 + + // 二分查找覆盖该码点的段 + let lo = 0 + let hi = segCount - 1 + let segment = -1 + while (lo <= hi) { + const mid = (lo + hi) >> 1 + const end = this.buffer.readUInt16BE(endOffset + mid * 2) + if (codePoint <= end) { + segment = mid + hi = mid - 1 + } else { + lo = mid + 1 + } + } + if (segment < 0) return 0 + + const start = this.buffer.readUInt16BE(startOffset + segment * 2) + if (codePoint < start) return 0 + + const delta = this.buffer.readInt16BE(deltaOffset + segment * 2) + const rangeOffset = this.buffer.readUInt16BE(rangeOffsetBase + segment * 2) + + if (rangeOffset === 0) { + return (codePoint + delta) & 0xffff + } + + const glyphIndexOffset = rangeOffsetBase + segment * 2 + rangeOffset + (codePoint - start) * 2 + if (glyphIndexOffset + 2 > this.buffer.length) return 0 + const glyph = this.buffer.readUInt16BE(glyphIndexOffset) + if (glyph === 0) return 0 + return (glyph + delta) & 0xffff + } + + private lookupFormat12(codePoint: number): number { + const t = this.cmapFormat12 + if (t < 0) return 0 + const nGroups = this.buffer.readUInt32BE(t + 12) + + // 二分查找码点所属分组 + let lo = 0 + let hi = nGroups - 1 + while (lo <= hi) { + const mid = (lo + hi) >> 1 + const group = t + 16 + mid * 12 + const startChar = this.buffer.readUInt32BE(group) + const endChar = this.buffer.readUInt32BE(group + 4) + if (codePoint < startChar) { + hi = mid - 1 + } else if (codePoint > endChar) { + lo = mid + 1 + } else { + const startGlyph = this.buffer.readUInt32BE(group + 8) + return startGlyph + (codePoint - startChar) + } + } + return 0 + } +} diff --git a/code/one-designer/packages/report-layout/src/fonts.ts b/code/one-designer/packages/report-layout/src/fonts.ts new file mode 100644 index 00000000..48c043e2 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/fonts.ts @@ -0,0 +1,36 @@ +/** + * @report/layout/fonts —— Node / Electron 侧真实字体度量入口。 + * + * 与主入口分离的原因:这里依赖 node:fs,浏览器打包不应被迫引入 + * Node 内置模块。浏览器侧应由宿主提供 Canvas 版 TextMeasurer。 + * + * 典型用法: + * + * const library = new FontLibrary() + * library.load({ family: "Arial", path: "C:/Windows/Fonts/arial.ttf" }) + * library.load({ family: "SimHei", path: "C:/Windows/Fonts/simhei.ttf" }) + * const measurer = createFontMeasurer({ + * library, + * fallbackFamilies: ["SimHei"], + * }) + * await layoutDocument(doc, ctx, { textMeasurer: measurer, fontResolver: measurer.resolver }) + */ + +// 字体解析 +export type { FontMetrics } from "./font-parser.js" +export { ParsedFont, FontParseError } from "./font-parser.js" + +// 字体加载与库 +export type { FontSource } from "./load-font.js" +export { + FontLibrary, + loadFontFile, + parseFontBuffer, +} from "./load-font.js" + +// 基于真实字体的度量器 +export type { FontMeasurerOptions, FontFallbackEvent } from "./font-measurer.js" +export { createFontMeasurer } from "./font-measurer.js" + +// 常见字体探测辅助 +export { defaultFontSources, findSystemFontSources } from "./system-fonts.js" diff --git a/code/one-designer/packages/report-layout/src/group.ts b/code/one-designer/packages/report-layout/src/group.ts new file mode 100644 index 00000000..faed5874 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/group.ts @@ -0,0 +1,367 @@ +/** + * 分组展开(对应设计文档 §6.1、§16 阶段 3)。 + * + * 分组语义: + * - 数据集行按 groupExpression 求值,**相邻行值相同**即属于同一组。 + * 这与 SQL 的 ORDER BY + GROUP BY 语义一致:分组不改变行顺序, + * 只按当前顺序切分。因此宿主应先按分组键排序再传入。 + * - 支持多级分组:groupLevel 小的为外层组,嵌套关系为 + * 外层组头 → 内层组头 → 详细行 → 内层组脚 → 外层组脚。 + * - 组脚可聚合该组的行(sum/avg/count),通过 group 作用域访问。 + * + * 跨页处理: + * - 组头默认与组内首行保持在一起(keepTogether),避免组头孤立在页尾。 + * - 组脚同理,不与最后一行分离。 + * - 组的聚合值在**组结束时**才可知,因此组脚块的渲染是延迟求值的。 + */ +import type { Diagnostic, ReportSection } from "@report/core" +import { DIAGNOSTIC_CODES } from "@report/core" +import type { EvalScope } from "@report/expression" +import { compile, evaluateCompiled } from "@report/expression" +import type { LayoutBlock } from "./paginate.js" +import { toLayoutNode } from "./paginate.js" +import type { LayoutNode } from "./layout-document.js" +import type { MeasureContext, MeasuredElement } from "./measure.js" +import { measureElement } from "./measure.js" + +/** 一个分组实例。 */ +export interface GroupInstance { + /** 分组键值。 */ + key: unknown + /** 分组级别(从 1 开始)。 */ + level: number + /** 该组包含的行索引区间 [start, end)。 */ + start: number + end: number + /** 该组对应的行(浅引用)。 */ + rows: ReadonlyArray> + /** 是否是该级别的第一个组。 */ + isFirst: boolean + /** 是否是该级别的最后一个组。 */ + isLast: boolean +} + +/** 分组计算结果。 */ +export interface GroupTree { + /** 按顺序排列的分组实例,按 level 从小到大、再按出现顺序。 */ + groups: GroupInstance[] + /** 分组过程中产生的诊断。 */ + diagnostics: Diagnostic[] +} + +/** 单级分组的描述。 */ +export interface GroupLevelSpec { + level: number + expression: string + headerSection?: ReportSection + footerSection?: ReportSection +} + +/** + * 计算分组树。 + * + * 只做"按相邻行切分",不排序——排序是宿主的职责, + * 因为 core/layout 不应假设数据来源是否可排序。 + */ +export function computeGroups( + rows: ReadonlyArray>, + levels: readonly GroupLevelSpec[], + diagnostics: Diagnostic[], +): GroupTree { + const groups: GroupInstance[] = [] + + if (levels.length === 0 || rows.length === 0) { + return { groups, diagnostics } + } + + // 外层到内层依次切分:每一层的分组区间是父区间的细分 + for (const spec of [...levels].sort((a, b) => a.level - b.level)) { + const compiled = compile(spec.expression) + if (!compiled.ast) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.EXPRESSION_PARSE_ERROR, + message: `分组表达式解析失败:${compiled.error ?? "未知错误"}(${spec.expression})`, + }) + continue + } + + const boundaries: { key: unknown; start: number }[] = [] + let previousKey: unknown = Symbol("init") + let hasPrevious = false + + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]! + const scope: EvalScope = { row, index, rowNumber: index + 1 } + const { value } = evaluateCompiled(compiled, { scope }) + + // 相邻值相同则并入上一组;首个行无条件开组 + if (!hasPrevious || !isSameGroupKey(previousKey, value)) { + boundaries.push({ key: value, start: index }) + previousKey = value + hasPrevious = true + } + } + + boundaries.forEach((boundary, i) => { + const next = boundaries[i + 1] + groups.push({ + key: boundary.key, + level: spec.level, + start: boundary.start, + end: next ? next.start : rows.length, + rows: rows.slice(boundary.start, next ? next.start : rows.length), + isFirst: i === 0, + isLast: i === boundaries.length - 1, + }) + }) + } + + // 排序:先按出现位置,再按 level,保证组头/组脚按嵌套顺序输出 + groups.sort((a, b) => (a.start - b.start) || (a.level - b.level)) + return { groups, diagnostics } +} + +/** 判断两个分组键是否属于同一组。 */ +export function isSameGroupKey(a: unknown, b: unknown): boolean { + if (a === b) return true + if (a === null || a === undefined) return b === null || b === undefined + if (b === null || b === undefined) return false + // 数字与字符串混合时按字符串比较,避免 1 与 "1" 被当成不同组 + return String(a) === String(b) +} + +/** + * 构建分组相关的分页块。 + * + * 输出顺序(多级分组时按嵌套展开): + * 每个组:组头块 → 子组头块 → 该组的详细行 → 子组脚块 → 组脚块 + * + * @param groupSections 参与分组的区域(group-header / group-footer) + * @param detailBlocks 详细行块工厂:给出行的索引区间,返回对应的块 + */ +export function buildGroupBlocks(options: { + groups: readonly GroupInstance[] + levels: readonly GroupLevelSpec[] + ctx: MeasureContext + parentWidth: number + sectionPath: string + diagnostics: Diagnostic[] + /** 生成某一行区间的详细块。 */ + buildDetailBlocks(start: number, end: number): LayoutBlock[] + /** 数据集全部行,用于 dataset 作用域聚合。 */ + allRows: ReadonlyArray> +}): LayoutBlock[] { + const { + groups, + levels, + ctx, + parentWidth, + sectionPath, + diagnostics, + buildDetailBlocks, + allRows, + } = options + + const blocks: LayoutBlock[] = [] + + // 顶层组(level 最小的那批)按 start 顺序遍历,逐组展开 + const topLevel = Math.min(...levels.map((l) => l.level)) + const topGroups = groups.filter((g) => g.level === topLevel) + + for (const group of topGroups) { + emitGroup(group, blocks) + } + + function emitGroup(group: GroupInstance, out: LayoutBlock[]): void { + const headerSpec = levels.find((l) => l.level === group.level && l.headerSection) + const footerSpec = levels.find((l) => l.level === group.level && l.footerSection) + + // 组头 + if (headerSpec?.headerSection) { + const block = buildGroupSectionBlock( + headerSpec.headerSection, + group, + ctx, + parentWidth, + `${sectionPath}/group-header/L${group.level}`, + diagnostics, + allRows, + "header", + ) + if (block) { + // 组头与组内首行不分离;组声明 pageBreakBefore 时每个组独占一页起始 + block.keepWithNext = true + if (headerSpec.headerSection.pageBreakBefore) { + block.pageBreakBefore = true + } + out.push(block) + } + } + + // 内层子组:找出完全落在本组区间内的下一级组 + const childLevel = levels + .map((l) => l.level) + .filter((l) => l > group.level) + .sort((a, b) => a - b)[0] + + if (childLevel !== undefined) { + const children = groups.filter( + (g) => g.level === childLevel && g.start >= group.start && g.end <= group.end, + ) + if (children.length > 0) { + for (const child of children) emitGroup(child, out) + } else { + // 无子组数据时仍输出详细行 + out.push(...buildDetailBlocks(group.start, group.end)) + } + } else { + out.push(...buildDetailBlocks(group.start, group.end)) + } + + // 组脚 + if (footerSpec?.footerSection) { + const block = buildGroupSectionBlock( + footerSpec.footerSection, + group, + ctx, + parentWidth, + `${sectionPath}/group-footer/L${group.level}`, + diagnostics, + allRows, + "footer", + ) + if (block) { + // 组脚与组内末行不分离,避免组脚被单独甩到下一页 + block.keepWithPrevious = true + out.push(block) + } + } + } + + return blocks +} + +/** + * 把一个分组区域转成分页块。 + * + * 组脚需要聚合整组数据,因此这里构造 group 作用域: + * - group.key:分组键 + * - group.rows:该组行数组 + * - group.count:行数 + * - 以及每一列的值数组,便于 sum(group.amounts) 这类写法 + */ +function buildGroupSectionBlock( + section: ReportSection, + group: GroupInstance, + ctx: MeasureContext, + parentWidth: number, + path: string, + diagnostics: Diagnostic[], + allRows: ReadonlyArray>, + role: "header" | "footer", +): LayoutBlock | null { + if (section.children.length === 0) return null + + // 构造聚合列:把每列的值收集成数组 + const columns: Record = {} + for (const row of group.rows) { + for (const [key, value] of Object.entries(row)) { + if (!columns[key]) columns[key] = [] + columns[key]!.push(value) + } + } + + const scope: EvalScope = { + row: group.rows[0] ?? {}, + group: { + key: group.key, + rows: group.rows, + count: group.rows.length, + ...columns, + }, + index: group.start, + rowNumber: group.start + 1, + params: ctx.renderContext.parameters, + dataset: { + rows: allRows, + count: allRows.length, + }, + } + + // 测量该区域的所有元素(此时 group 作用域已就绪,聚合表达式可求值) + const measured = section.children.map((element, i) => { + const m = measureElement(element, parentWidth, ctx, scope, `${path}/children/${i}`) + diagnostics.push(...m.diagnostics) + return m + }) + + let maxBottom = 0 + for (const m of measured) { + const bottom = m.frame.y + m.frame.height + if (bottom > maxBottom) maxBottom = bottom + } + const height = maxBottom + if (height <= 0) return null + + return { + height, + render: (top: number): LayoutNode[] => measured.map((m) => toNode(m, top)), + } +} + +/** 把已测量元素转换为布局节点(复用 paginate 的转换逻辑,保证坐标处理一致)。 */ +function toNode(element: MeasuredElement, top: number): LayoutNode { + return toLayoutNode(element, top) +} + +/** 收集文档中的分组级别定义。 */ +export function collectGroupLevels( + sections: readonly ReportSection[], + diagnostics: Diagnostic[], +): GroupLevelSpec[] { + const byLevel = new Map() + + for (const section of sections) { + if (section.kind !== "group-header" && section.kind !== "group-footer") continue + + if (!section.groupExpression) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.SCHEMA_REQUIRED_MISSING, + message: `区域 "${section.kind}" 缺少 groupExpression,已忽略`, + path: `/sections/${section.id}`, + }) + continue + } + + const level = section.groupLevel ?? 1 + const existing = byLevel.get(level) + if (existing) { + if (section.kind === "group-header") existing.headerSection = section + else existing.footerSection = section + // 同一级别的两处声明必须使用同一表达式,否则分组语义不明确 + if (existing.expression !== section.groupExpression) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.SCHEMA_INVALID, + message: + `分组级别 ${level} 的 group-header 与 group-footer 使用了不同的 ` + + `groupExpression("${existing.expression}" 与 "${section.groupExpression}"),` + + `已以前者为准`, + path: `/sections/${section.id}`, + }) + } + } else { + byLevel.set(level, { + level, + expression: section.groupExpression, + ...(section.kind === "group-header" + ? { headerSection: section } + : { footerSection: section }), + }) + } + } + + return [...byLevel.values()].sort((a, b) => a.level - b.level) +} diff --git a/code/one-designer/packages/report-layout/src/index.ts b/code/one-designer/packages/report-layout/src/index.ts new file mode 100644 index 00000000..8edbda9b --- /dev/null +++ b/code/one-designer/packages/report-layout/src/index.ts @@ -0,0 +1,133 @@ +/** + * @report/layout 公共入口(对应设计文档 §4.3、§9)。 + * + * Layout 负责尺寸计算、文本测量、换行、表格展开、动态高度和分页, + * 产出所有 Renderer 共用的 LayoutDocument。 + * + * 本入口为纯逻辑部分,不接触文件系统。需要 Node 侧真实字体度量时, + * 从 "@report/layout/fonts" 导入。 + */ + +// 中间结果模型 +export type { + LayoutDocument, + LayoutPage, + LayoutPageSection, + LayoutNode, + LayoutNodeRole, + LayoutContent, + LayoutDiagnostic, + TextContent, + ImageContent, + LineContent, + RectContent, + TableContent, + TableRow, + TableCellContent, + TableColumnLayout, +} from "./layout-document.js" +export { + createLayoutDocument, + walkLayoutNodes, + countLayoutNodes, +} from "./layout-document.js" + +// 单位换算 +export { + MM_PER_INCH, + PX_PER_INCH, + PT_PER_INCH, + mmToPx, + pxToMm, + mmToPt, + ptToMm, + ptToPx, + pxToPt, + fontSizeToMm, +} from "./units.js" + +// 环境能力接口 +export type { + TextMeasurer, + TextMetrics, + FontResolver, + ResolvedFont, +} from "./measurer.js" +export { + createEstimateMeasurer, + createPassthroughFontResolver, + estimateCharWidth, +} from "./measurer.js" + +// 文本换行 +export type { WrapOptions, WrapResult } from "./text-wrap.js" +export { + wrapText, + segmentText, + measureTextHeight, + isTextOverflow, + isNoLineStart, + isNoLineEnd, +} from "./text-wrap.js" + +// 运行时上下文 +export type { RenderContext, LayoutOptions } from "./context.js" +export { + createRenderContext, + resolveDataset, + resolveAllDatasets, + collectRequiredDatasetIds, +} from "./context.js" + +// 测量 +export type { MeasuredElement, MeasureContext } from "./measure.js" +export { + createMeasureContext, + measureElement, + measureSection, + contentHeight, + measureInlineText, + resolveElementStyle, +} from "./measure.js" + +// 分页 +export type { + MeasuredSection, + LayoutBlock, + PaginationContext, + PageGeometry, +} from "./paginate.js" +export { + paginate, + computePageGeometry, + renderElements, + toLayoutNode, + toMeasuredSection, + isTextNode, + nodeStyle, +} from "./paginate.js" + +// 明细表展开 +export { + buildTableBlocks, + estimateTableRowsPerPage, + isExpandableTable, + declaredTableWidth, +} from "./table.js" + +// 分组 +export type { GroupInstance, GroupTree, GroupLevelSpec } from "./group.js" +export { + computeGroups, + isSameGroupKey, + buildGroupBlocks, + collectGroupLevels, +} from "./group.js" + +// 引擎 +export type { LayoutEngine } from "./engine.js" +export { + DefaultLayoutEngine, + createLayoutEngine, + layoutDocument, +} from "./engine.js" diff --git a/code/one-designer/packages/report-layout/src/layout-document.ts b/code/one-designer/packages/report-layout/src/layout-document.ts new file mode 100644 index 00000000..30a76a1e --- /dev/null +++ b/code/one-designer/packages/report-layout/src/layout-document.ts @@ -0,0 +1,172 @@ +/** + * Layout 中间结果模型(对应设计文档 §9)。 + * + * LayoutDocument 是所有 Renderer 共用的分页结果:Renderer 只消费它, + * 绝不重新计算分页(§3.3)。 + */ +import type { Diagnostic, Rect, ResolvedStyle } from "@report/core" + +/** 布局诊断。与统一 Diagnostic 同构,便于跨包传递。 */ +export type LayoutDiagnostic = Diagnostic + +/** 布局节点内容。按节点类型区分,Renderer 据此绘制。 */ +export interface TextContent { + kind: "text" + /** 已应用绑定的最终文本。 */ + text: string + /** 换行后的行,已由 Layout 计算完成。 */ + lines: string[] + /** 单行高度(mm)。 */ + lineHeight: number + /** 是否发生了溢出(内容高于 frame)。 */ + overflow: boolean +} + +export interface ImageContent { + kind: "image" + src: string + fit: "contain" | "cover" | "fill" | "none" + /** 原始像素尺寸(若已知)。 */ + naturalWidth?: number + naturalHeight?: number +} + +export interface LineContent { + kind: "line" + direction: "horizontal" | "vertical" + /** 线宽(mm)。 */ + thickness: number +} + +export interface RectContent { + kind: "rect" +} + +/** 明细表内容。 */ +export interface TableContent { + kind: "table" + /** 已展开的行,每行是按列切分后的单元格数据。 */ + rows: TableRow[] + columns: TableColumnLayout[] + /** 本页是否渲染表头。 */ + showHeader: boolean + headerHeight: number + rowHeight: number + showGrid: boolean +} + +export interface TableColumnLayout { + id: string + /** 相对表格左边界的偏移(mm)。 */ + x: number + width: number + align: "left" | "center" | "right" + header?: string +} + +export interface TableRow { + /** 数据集中的行索引(从 0 开始)。 */ + index: number + /** 序号(从 1 开始)。 */ + rowNumber: number + /** 每个单元格已求值的文本。 */ + cells: TableCellContent[] + /** 行高(mm)。 */ + height: number +} + +export interface TableCellContent { + columnId: string + text: string + value?: unknown +} + +/** 节点内容联合类型。 */ +export type LayoutContent = + | TextContent + | ImageContent + | LineContent + | RectContent + | TableContent + +/** 布局节点。 */ +export interface LayoutNode { + /** 对应 ReportElement.id。 */ + id: string + type: string + /** 绝对坐标(mm),原点在页面左上角。 */ + frame: Rect + style: ResolvedStyle + content?: LayoutContent + children?: LayoutNode[] + /** 节点种类,便于 Renderer 快速分派。 */ + role: LayoutNodeRole +} + +/** 节点角色。 */ +export type LayoutNodeRole = + | "element" + | "table-header" + | "table-row" + | "table-cell" + +/** 布局页。 */ +export interface LayoutPage { + /** 页宽(mm)。 */ + width: number + /** 页高(mm)。 */ + height: number + /** 页码(从 1 开始)。 */ + pageNumber: number + nodes: LayoutNode[] + /** 本页来源区域,便于调试与诊断。 */ + sections: LayoutPageSection[] +} + +/** 页面中某个区域的落位信息。 */ +export interface LayoutPageSection { + kind: string + sectionId: string + /** 该区域在本页占用的垂直范围(mm,绝对坐标)。 */ + top: number + bottom: number +} + +/** 布局文档。 */ +export interface LayoutDocument { + pages: LayoutPage[] + diagnostics: LayoutDiagnostic[] + /** 总页数,便于页脚渲染"第 X 页 / 共 Y 页"。 */ + pageCount: number +} + +/** 创建空布局文档。 */ +export function createLayoutDocument( + pages: LayoutPage[] = [], + diagnostics: LayoutDiagnostic[] = [], +): LayoutDocument { + return { pages, diagnostics, pageCount: pages.length } +} + +/** 遍历布局文档的所有节点(含嵌套)。 */ +export function walkLayoutNodes( + document: LayoutDocument, + visit: (node: LayoutNode, page: LayoutPage) => void, +): void { + const walk = (nodes: readonly LayoutNode[], page: LayoutPage): void => { + for (const node of nodes) { + visit(node, page) + if (node.children?.length) walk(node.children, page) + } + } + for (const page of document.pages) walk(page.nodes, page) +} + +/** 统计布局文档中的节点总数。 */ +export function countLayoutNodes(document: LayoutDocument): number { + let count = 0 + walkLayoutNodes(document, () => { + count += 1 + }) + return count +} diff --git a/code/one-designer/packages/report-layout/src/load-font.ts b/code/one-designer/packages/report-layout/src/load-font.ts new file mode 100644 index 00000000..44cd94a3 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/load-font.ts @@ -0,0 +1,253 @@ +/** + * 字体加载与字体库(对应设计文档 §9 的 FontResolver)。 + * + * 负责从文件读取 TTF / OTF / TTC,并解析成可查询的 ParsedFont。 + * 与 font-parser.ts 的分工: + * - font-parser.ts:纯字节解析,不接触文件系统,可用于浏览器(配合宿主提供的 buffer)。 + * - load-font.ts:文件加载与字体族匹配,仅在 Node / Electron 主进程使用。 + */ +import { readFileSync, existsSync } from "node:fs" +import { basename, extname } from "node:path" +import { FontParseError, ParsedFont, type FontMetrics } from "./font-parser.js" + +interface TableRecord { + offset: number + length: number +} + +/** 判断 buffer 是否为 TrueType Collection(ttcf)。 */ +function isCollection(buffer: Buffer): boolean { + return ( + buffer.length >= 4 && + buffer[0] === 0x74 && // t + buffer[1] === 0x74 && // t + buffer[2] === 0x63 && // c + buffer[3] === 0x66 // f + ) +} + +/** 读取字体表目录。 */ +function readTables(buffer: Buffer, base: number): Map { + if (base + 12 > buffer.length) { + throw new FontParseError("字体数据不完整,无法读取表目录") + } + const numTables = buffer.readUInt16BE(base + 4) + const tables = new Map() + for (let i = 0; i < numTables; i += 1) { + const record = base + 12 + i * 16 + if (record + 16 > buffer.length) break + const tag = buffer.toString("ascii", record, record + 4) + tables.set(tag, { + offset: buffer.readUInt32BE(record + 8), + length: buffer.readUInt32BE(record + 12), + }) + } + return tables +} + +/** 从 name 表读取名称。 */ +function readName(buffer: Buffer, base: number, tables: Map, nameId: number): string { + const name = tables.get("name") + if (!name) return "" + const count = buffer.readUInt16BE(name.offset + 2) + const stringOffset = name.offset + buffer.readUInt16BE(name.offset + 4) + + for (let i = 0; i < count; i += 1) { + const record = name.offset + 6 + i * 12 + if (record + 12 > buffer.length) break + const platformId = buffer.readUInt16BE(record) + const currentNameId = buffer.readUInt16BE(record + 6) + if (currentNameId !== nameId) continue + + const length = buffer.readUInt16BE(record + 8) + const offset = buffer.readUInt16BE(record + 10) + const start = stringOffset + offset + if (start + length > buffer.length) continue + + if (platformId === 3 || platformId === 0) { + // UTF-16BE:Node 不支持该 encoding,按码元手工解码 + let out = "" + for (let k = 0; k + 1 < length; k += 2) { + out += String.fromCharCode(buffer.readUInt16BE(start + k)) + } + if (out) return out + } else if (platformId === 1) { + let out = "" + for (let k = 0; k < length; k += 1) out += String.fromCharCode(buffer[start + k]!) + if (out) return out + } + } + return "" +} + +/** 从 buffer 解析字体。 */ +export function parseFontBuffer( + buffer: Buffer, + options: { fontIndex?: number; source?: string } = {}, +): ParsedFont { + const { fontIndex = 0, source } = options + + let base = 0 + if (isCollection(buffer)) { + const numFonts = buffer.readUInt32BE(8) + if (fontIndex >= numFonts) { + throw new FontParseError( + `字体集合只有 ${numFonts} 个字体,请求索引 ${fontIndex} 越界`, + ) + } + base = buffer.readUInt32BE(12 + fontIndex * 4) + } + + const tables = readTables(buffer, base) + + const head = tables.get("head") + const hhea = tables.get("hhea") + const maxp = tables.get("maxp") + if (!head) throw new FontParseError("字体缺少 head 表") + if (!hhea) throw new FontParseError("字体缺少 hhea 表") + if (!maxp) throw new FontParseError("字体缺少 maxp 表") + + const unitsPerEm = buffer.readUInt16BE(head.offset + 18) + if (unitsPerEm === 0) throw new FontParseError("字体的 unitsPerEm 为 0,数据无效") + + const metrics: FontMetrics = { + familyName: readName(buffer, base, tables, 1), + fullName: readName(buffer, base, tables, 4), + unitsPerEm, + numGlyphs: buffer.readUInt16BE(maxp.offset + 4), + numberOfHMetrics: buffer.readUInt16BE(hhea.offset + 34), + ascender: buffer.readInt16BE(hhea.offset + 4), + descender: buffer.readInt16BE(hhea.offset + 6), + lineGap: buffer.readInt16BE(hhea.offset + 8), + ...(source !== undefined ? { source } :{}), + } + + if (!metrics.familyName) { + metrics.familyName = source ? basename(source, extname(source)) : "unknown" + } + + return new ParsedFont(buffer, base, metrics, tables) +} + +/** 从文件路径解析字体。 */ +export function loadFontFile( + path: string, + options: { fontIndex?: number } = {}, +): ParsedFont { + if (!existsSync(path)) { + throw new FontParseError(`字体文件不存在:${path}`) + } + const buffer = readFileSync(path) + return parseFontBuffer(buffer, { ...options, source: path }) +} + +/** 字体来源配置。 */ +export interface FontSource { + /** 字体族名(用于匹配 style.fontFamily)。 */ + family: string + /** 文件路径。 */ + path: string + /** TTC 内的索引。 */ + fontIndex?: number +} + +/** 字体库:按族名查找已加载字体。 */ +export class FontLibrary { + private readonly fonts = new Map() + private readonly byPath = new Map() + private readonly failures: { family: string; path: string; message: string }[] = [] + + /** 已成功加载的字体族(小写)。 */ + families(): string[] { + return [...this.fonts.keys()].sort() + } + + /** 加载失败的记录,用于诊断。 */ + loadFailures(): readonly { family: string; path: string; message: string }[] { + return this.failures + } + + /** 注册一个字体对象。 */ + register(family: string, font: ParsedFont): void { + this.fonts.set(family.toLowerCase(), font) + const source = font.metrics.source + if (source) this.byPath.set(source, font) + } + + /** 从文件加载并注册。加载失败时记录而不抛异常,保证宿主仍可继续。 */ + load(source: FontSource): boolean { + try { + const font = loadFontFile(source.path, { + ...(source.fontIndex !== undefined ? { fontIndex: source.fontIndex } :{}), + }) + this.register(source.family, font) + return true + } catch (cause) { + this.failures.push({ + family: source.family, + path: source.path, + message: (cause as Error).message, + }) + return false + } + } + + /** 批量加载。 */ + loadAll(sources: readonly FontSource[]): void { + for (const source of sources) this.load(source) + } + + /** 按族名精确查找(大小写不敏感)。 */ + get(family: string): ParsedFont | undefined { + return this.fonts.get(family.toLowerCase()) + } + + has(family: string): boolean { + return this.fonts.has(family.toLowerCase()) + } + + /** + * 解析字体族,支持 CSS 风格的字体栈。 + * + * 依次尝试栈中每个族名,命中即返回。全部未命中时返回 undefined。 + */ + resolveStack(fontFamily: string): { font: ParsedFont; family: string } | undefined { + const candidates = fontFamily + .split(",") + .map((name) => name.trim().replace(/^["']|["']$/g, "")) + .filter((name) => name.length > 0) + + for (const name of candidates) { + const font = this.get(name) + if (font) return { font, family: name } + } + return undefined + } + + /** + * 查找能覆盖指定字符的字体。 + * + * 这正是中英混排的关键:拉丁字体通常不含汉字, + * 需要按字符回退到 CJK 字体。 + */ + findForCodePoint(codePoint: number, preferred: readonly string[] = []): ParsedFont | undefined { + for (const name of preferred) { + const font = this.get(name) + if (font?.hasGlyph(codePoint)) return font + } + for (const font of this.fonts.values()) { + if (font.hasGlyph(codePoint)) return font + } + return undefined + } + + get size(): number { + return this.fonts.size + } + + clear(): void { + this.fonts.clear() + this.byPath.clear() + this.failures.length = 0 + } +} diff --git a/code/one-designer/packages/report-layout/src/measure.ts b/code/one-designer/packages/report-layout/src/measure.ts new file mode 100644 index 00000000..27500e07 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/measure.ts @@ -0,0 +1,251 @@ +/** + * 元素测量(对应设计文档 §3.3、§4.3)。 + * + * 负责把 ReportElement 转成"已确定尺寸"的中间结构: + * 解析样式、求值绑定、计算动态高度。 + * + * 本文件只做测量,不做分页;分页由 paginate.ts 完成。 + * 这样"测量"与"分页"两项职责各自独立可测。 + */ +import type { + DataProvider, + Diagnostic, + Rect, + ReportDocument, + ReportElement, + ReportSection, + ResolvedStyle, + TableElement, +} from "@report/core" +import { + DIAGNOSTIC_CODES, + resolveStyleChain, + isTableElement, + measureTableWidth, +} from "@report/core" +import type { EvalScope } from "@report/expression" +import { ExpressionCache, resolveBinding } from "@report/expression" +import type { FontResolver, TextMeasurer, TextMetrics } from "./measurer.js" +import { wrapText } from "./text-wrap.js" +import type { RenderContext } from "./context.js" + +/** 已测量的元素。 */ +export interface MeasuredElement { + element: ReportElement + style: ResolvedStyle + /** 元素最终尺寸(mm)。动态高度会覆盖原始 frame.height。 */ + frame: Rect + /** 文本内容(text 类型)。 */ + text?: string + /** 换行结果(text 类型)。 */ + wrapped?: { lines: string[]; lineHeight: number; totalHeight: number } + /** 是否发生溢出。 */ + overflow: boolean + children: MeasuredElement[] + diagnostics: Diagnostic[] +} + +/** 测量上下文。 */ +export interface MeasureContext { + document: ReportDocument + renderContext: RenderContext + measurer: TextMeasurer + fontResolver: FontResolver + cache: ExpressionCache + /** 文本溢出的容差(mm),避免浮点误差导致误报。 */ + overflowTolerance: number + diagnostics: Diagnostic[] +} + +/** 创建测量上下文。 */ +export function createMeasureContext(options: { + document: ReportDocument + renderContext: RenderContext + measurer: TextMeasurer + fontResolver: FontResolver +}): MeasureContext { + return { + document: options.document, + renderContext: options.renderContext, + measurer: options.measurer, + fontResolver: options.fontResolver, + cache: new ExpressionCache(), + overflowTolerance: 0.01, + diagnostics: [], + } +} + +/** 解析元素的最终样式(styleId 继承链 + 元素级覆盖)。 */ +export function resolveElementStyle( + element: ReportElement, + document: ReportDocument, + diagnostics: Diagnostic[], +): ResolvedStyle { + const { style, cycle } = resolveStyleChain(element.styleId, document.styles) + if (cycle) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.REF_CYCLE_DETECTED, + message: `样式继承存在循环:${cycle.join(" → ")},已回退到默认样式`, + elementId: element.id, + }) + } + + // 元素级覆盖:style 字段直接覆盖已解析样式的同名字段 + if (!element.style) return style + const merged: ResolvedStyle = { ...style } + const override = element.style + if (typeof override.fontFamily === "string") merged.fontFamily = override.fontFamily + if (typeof override.fontSize === "number") merged.fontSize = override.fontSize + if (typeof override.color === "string") merged.color = override.color + if (typeof override.background === "string") merged.background = override.background + if (override.align === "left" || override.align === "center" || override.align === "right") { + merged.align = override.align + } + if ( + override.verticalAlign === "top" || + override.verticalAlign === "middle" || + override.verticalAlign === "bottom" + ) { + merged.verticalAlign = override.verticalAlign + } + if (typeof override.lineHeight === "number") merged.lineHeight = override.lineHeight + if (typeof override.fontWeight === "number" || typeof override.fontWeight === "string") { + merged.fontWeight = override.fontWeight as ResolvedStyle["fontWeight"] + } + return merged +} + +/** + * 测量单个元素。 + * + * text 元素会根据换行结果计算动态高度;其它类型沿用 frame 尺寸。 + */ +export function measureElement( + element: ReportElement, + parentWidth: number, + ctx: MeasureContext, + scope: EvalScope, + path: string, +): MeasuredElement { + const diagnostics: Diagnostic[] = [] + const style = resolveElementStyle(element, ctx.document, diagnostics) + + // 宽度以 frame 为准;若 frame 宽度为 0,则继承父容器宽度 + const width = element.frame.width > 0 ? element.frame.width : parentWidth + const frame: Rect = { ...element.frame, width } + + const measured: MeasuredElement = { + element, + style, + frame, + overflow: false, + children: [], + diagnostics, + } + + // 文本元素:求值绑定 + 换行 + 动态高度 + if (element.type === "text") { + const binding = element.binding + let text = "" + if (binding && (binding.field || binding.expression)) { + const resolved = resolveBinding(binding, { scope, elementId: element.id, path }, ctx.cache) + text = resolved.text + diagnostics.push(...resolved.diagnostics) + } else { + // 无绑定时取 props.text 作为静态文本 + const literal = element.props?.text + text = typeof literal === "string" ? literal : "" + } + + const innerWidth = Math.max(0, width - style.padding.left - style.padding.right) + const wrapped = wrapText(text, { maxWidth: innerWidth, style, measurer: ctx.measurer }) + + measured.text = text + measured.wrapped = { + lines: wrapped.lines, + lineHeight: wrapped.lineHeight, + totalHeight: wrapped.totalHeight, + } + + // 动态高度:内容高于 frame 时以内容为准(§3.3) + const originalHeight = element.frame.height + if (wrapped.totalHeight > originalHeight + ctx.overflowTolerance) { + measured.frame.height = wrapped.totalHeight + style.padding.top + style.padding.bottom + } else { + measured.overflow = false + } + } + + // 子元素递归测量 + if (element.children?.length) { + for (let i = 0; i < element.children.length; i += 1) { + const child = element.children[i]! + measured.children.push( + measureElement(child, frame.width, ctx, scope, `${path}/children/${i}`), + ) + } + } + + // 明细表的列布局在展开阶段处理,这里只记录总宽度是否超出 + if (isTableElement(element)) { + const tableWidth = measureTableWidth(element as TableElement) + if (tableWidth > width + ctx.overflowTolerance) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.LAYOUT_TEXT_OVERFLOW, + message: `明细表列宽合计 ${tableWidth.toFixed(2)}mm 超出表格宽度 ${width.toFixed(2)}mm`, + elementId: element.id, + path, + }) + } + } + + return measured +} + +/** 测量一个区域的所有顶层元素。 */ +export function measureSection( + section: ReportSection, + contentWidth: number, + ctx: MeasureContext, + scope: EvalScope, + path: string, +): MeasuredElement[] { + return section.children.map((element, index) => + measureElement(element, contentWidth, ctx, scope, `${path}/children/${index}`), + ) +} + +/** + * 计算一组已测量元素的内容高度(mm)。 + * + * 取所有子元素底边的最大值,再加区域内边距。 + * 这决定了页眉/页脚/明细区在分页时占用的垂直空间。 + */ +export function contentHeight(elements: readonly MeasuredElement[], padding = 0): number { + let maxBottom = 0 + for (const element of elements) { + const bottom = element.frame.y + element.frame.height + if (bottom > maxBottom) maxBottom = bottom + } + return maxBottom + padding +} + +/** 测量一段文本,供表格单元格等场景复用。 */ +export function measureInlineText( + text: string, + width: number, + style: ResolvedStyle, + measurer: TextMeasurer, +): TextMetrics { + const result = wrapText(text, { maxWidth: width, style, measurer }) + return { + width: Math.max(...result.widths, 0), + height: result.totalHeight, + ascent: result.lineHeight * 0.8, + descent: result.lineHeight * 0.2, + fontFamily: style.fontFamily, + fallback: false, + } +} diff --git a/code/one-designer/packages/report-layout/src/measurer.ts b/code/one-designer/packages/report-layout/src/measurer.ts new file mode 100644 index 00000000..51be5951 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/measurer.ts @@ -0,0 +1,125 @@ +/** + * 环境能力接口(对应设计文档 §9)。 + * + * Layout 通过 TextMeasurer、FontResolver 等接口获得环境能力,不直接依赖 DOM。 + * 这样可以分别适配浏览器、Node 和服务端字体环境,同时让分页逻辑保持独立。 + */ +import type { ResolvedStyle } from "@report/core" + +/** 文本度量结果。 */ +export interface TextMetrics { + /** 文本宽度(mm)。 */ + width: number + /** 单行高度(mm),已含行距。 */ + height: number + /** 基线到顶部的距离(mm)。 */ + ascent: number + /** 基线到底部的距离(mm)。 */ + descent: number + /** 实际参与度量的字体(用于诊断缺失字体)。 */ + fontFamily: string + /** 是否发生了字体回退。 */ + fallback: boolean +} + +/** 已解析的字体信息。 */ +export interface ResolvedFont { + /** 请求的字体族名。 */ + requested: string + /** 实际使用的字体族名。 */ + resolved: string + /** 是否发生了回退。 */ + fallback: boolean +} + +/** + * 文本度量器。 + * + * 浏览器侧通常用 Canvas measureText 实现;Node 侧用 TTF 度量表实现。 + * 度量单位统一为 mm,与文档模型一致。 + */ +export interface TextMeasurer { + measure(text: string, style: ResolvedStyle): TextMetrics +} + +/** 字体解析器。 */ +export interface FontResolver { + resolve(fontFamily: string): ResolvedFont +} + +/** + * 缺省度量器:按字符类别估算宽度。 + * + * 仅在宿主未注入真实度量器时使用,会产生 layout.font-fallback 诊断。 + * 估算规则基于东亚字宽惯例: + * - 全角字符(CJK、全角标点)宽 1.0 em + * - 半角字符宽 0.5 em + * - ASCII 按窄/中等/宽分档,粗略接近常见无衬线字体 + */ +export function createEstimateMeasurer(): TextMeasurer { + return { + measure(text: string, style: ResolvedStyle): TextMetrics { + const emMm = (style.fontSize / 72) * 25.4 + let width = 0 + for (const ch of text) { + width += estimateCharWidth(ch) * emMm + } + const height = emMm * style.lineHeight + return { + width, + height, + ascent: emMm * 0.8, + descent: emMm * 0.2, + fontFamily: style.fontFamily, + fallback: true, + } + }, + } +} + +/** 单个字符的宽度(以 em 为单位)。 */ +export function estimateCharWidth(ch: string): number { + const code = ch.codePointAt(0) ?? 0 + + // 控制字符不占宽 + if (code === 0x09) return 0.5 + + // CJK 统一表意文字、全角标点、假名、谚文:全角 + if ( + (code >= 0x1100 && code <= 0x115f) || + (code >= 0x2e80 && code <= 0xa4cf) || + (code >= 0xac00 && code <= 0xd7a3) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xfe30 && code <= 0xfe6f) || + (code >= 0xff00 && code <= 0xff60) || + (code >= 0xffe0 && code <= 0xffe6) || + (code >= 0x20000 && code <= 0x3fffd) + ) { + return 1.0 + } + + // ASCII 分档 + if (code < 0x80) { + const ch2 = ch + // 窄字符 + if ("iljtfr.,:;'|!()[]`".includes(ch2)) return 0.28 + if ("I".includes(ch2)) return 0.3 + // 宽字符 + if ("MWmw@".includes(ch2)) return 0.88 + if ("ABCDEFGHKNOPQRSTUVXYZ".includes(ch2)) return 0.68 + if (ch2 >= "0" && ch2 <= "9") return 0.556 + return 0.5 + } + + // 其它非 ASCII 按半角估算 + return 0.5 +} + +/** 缺省字体解析器:总是回退。 */ +export function createPassthroughFontResolver(): FontResolver { + return { + resolve(fontFamily: string): ResolvedFont { + return { requested: fontFamily, resolved: fontFamily, fallback: false } + }, + } +} diff --git a/code/one-designer/packages/report-layout/src/paginate.ts b/code/one-designer/packages/report-layout/src/paginate.ts new file mode 100644 index 00000000..4c6e111f --- /dev/null +++ b/code/one-designer/packages/report-layout/src/paginate.ts @@ -0,0 +1,550 @@ +/** + * 分页引擎(对应设计文档 §3.3、§9)。 + * + * 职责:把已测量的内容按页面内容区高度切分到多页,产出统一的 LayoutDocument。 + * + * 设计要点: + * - 分页只在这里做一次,所有 Renderer 消费同一份结果(§3.3)。 + * - 页眉/页脚每页重复;明细区独占中间的垂直空间。 + * - 页脚占用的高度必须先从可用高度中扣除,否则最后一页会溢出。 + * - 分页失败产生诊断而不是抛异常(§13)。 + */ +import type { + Diagnostic, + Rect, + ReportDocument, + ResolvedStyle, +} from "@report/core" +import { DIAGNOSTIC_CODES, resolvePageSize, resolveContentBox } from "@report/core" +import type { + LayoutDocument, + LayoutNode, + LayoutPage, + LayoutPageSection, + TextContent, +} from "./layout-document.js" +import type { MeasuredElement } from "./measure.js" +import { contentHeight } from "./measure.js" + +/** 分页上下文。 */ +export interface PaginationContext { + document: ReportDocument + diagnostics: Diagnostic[] + /** 允许元素跨页拆分(V1 暂不支持,保留接口)。 */ + allowSplit: boolean +} + +/** 页面几何信息。 */ +export interface PageGeometry { + width: number + height: number + contentX: number + contentY: number + contentWidth: number + contentHeight: number +} + +/** 计算页面几何。 */ +export function computePageGeometry(document: ReportDocument): PageGeometry { + const size = resolvePageSize(document.page) + const box = resolveContentBox(document.page) + return { + width: size.width, + height: size.height, + contentX: box.x, + contentY: box.y, + contentWidth: box.width, + contentHeight: box.height, + } +} + +/** 参与分页的已测量区域。 */ +export interface MeasuredSection { + sectionId: string + kind: string + elements: MeasuredElement[] + /** 内容高度(mm)。 */ + height: number + /** 是否每页重复。 */ + repeatOnEachPage: boolean + /** 是否允许跨页拆分。 */ + allowSplit: boolean + /** + * 可拆分的内容块(按行拆分的明细表等)。 + * 存在时优先按块分页,否则整块作为一个不可分单元。 + */ + blocks?: LayoutBlock[] +} + +/** 块种类。 */ +export type LayoutBlockKind = "content" | "header" | "spacer" + +/** 可独立分页的内容块。 */ +export interface LayoutBlock { + /** 块渲染函数:给出在页内的起始 Y,返回布局节点。 */ + render(top: number): LayoutNode[] + /** 块高度(mm)。 */ + height: number + /** + * 块种类。 + * + * "header" 表示这是表头块——它**始终渲染一次**, + * 是否在后续页面重复由 repeatOnEachPage 决定。 + * 这两个概念必须分开:早期实现用一个布尔同时表达两者, + * 导致把"不重复"误当成"不渲染",表头直接消失。 + */ + kind?: LayoutBlockKind + /** 是否在后续每一页重复渲染本块。仅对 header 块有意义。 */ + repeatOnEachPage?: boolean + /** + * 对应的数据行索引(从 0 开始)。 + * + * 明细表为每个数据行产出一个块,这里记录它来自哪一行。 + * 分组需要按**行身份**选取块,而不能依赖数组下标—— + * 因为块数组里还混有表头、空数据占位等非行块, + * 按下标切片会错位,导致行被丢弃或重复输出。 + */ + rowIndex?: number + /** + * 本块必须与**下一个**块保持在同页(组头与组内首行不分离)。 + * + * 分页引擎在填块时若发现本块后紧跟着放不下下一块, + * 会尝试把本块也挪到下一页,避免组头孤立在页尾。 + */ + keepWithNext?: boolean + /** + * 本块必须与**上一个**块保持在同页(组脚与组内末行不分离)。 + * + * 与 keepWithNext 成对出现,二者表达的是同一段不可分关系的两端。 + * 分页引擎据此保证组脚不会被单独甩到下一页。 + */ + keepWithPrevious?: boolean + /** + * 本块之前强制换页(组变更时每个分组独占一页起始)。 + * + * 当本块是某组的首个块且声明了 pageBreakBefore 时, + * 分页引擎会结束当前页并从新页开始输出本组。 + */ + pageBreakBefore?: boolean +} + +/** + * 分页主流程。 + * + * @param sections 已测量并按文档顺序排列的区域 + */ +export function paginate( + sections: readonly MeasuredSection[], + geometry: PageGeometry, + ctx: PaginationContext, +): LayoutDocument { + const pages: LayoutPage[] = [] + const diagnostics: Diagnostic[] = [...ctx.diagnostics] + + // 区分每页重复区域与主体区域 + const repeating = sections.filter((s) => s.repeatOnEachPage && s.kind !== "detail") + const body = sections.filter((s) => !(s.repeatOnEachPage && s.kind !== "detail")) + + const headerHeight = repeating + .filter((s) => s.kind === "page-header") + .reduce((sum, s) => sum + s.height, 0) + const footerHeight = repeating + .filter((s) => s.kind === "page-footer") + .reduce((sum, s) => sum + s.height, 0) + + // 每页可容纳的主体高度:内容区高度 - 页眉 - 页脚 + const bodyPerPage = geometry.contentHeight - headerHeight - footerHeight + + if (bodyPerPage <= 0) { + diagnostics.push({ + severity: "error", + code: DIAGNOSTIC_CODES.LAYOUT_PAGE_OVERFLOW, + message: + `页眉与页脚高度合计 ${(headerHeight + footerHeight).toFixed(2)}mm ` + + `已达到或超过页面内容区高度 ${geometry.contentHeight.toFixed(2)}mm,无法排版主体内容`, + path: "/sections", + }) + return { pages: [], diagnostics, pageCount: 0 } + } + + // 把主体区域摊平成有序的块序列 + const stream = flattenBlocks(body, ctx, diagnostics) + + if (stream.length === 0) { + // 没有任何主体内容,仍产出一页(只有页眉页脚) + pages.push(buildPage(1, repeating, [], geometry, undefined, 0, 0)) + return { pages, diagnostics, pageCount: pages.length } + } + + let cursor = 0 + let pageNumber = 1 + + // 已出现的表头块:翻页时需要在新页顶部重新插入,实现"每页重复表头"。 + // 之前的实现只在注释里提到这一点却没有真正补插,导致表头只出现在第一页。 + const activeHeaders: LayoutBlock[] = [] + let headerHeightTotal = 0 + + while (cursor < stream.length) { + let used = 0 + const pageBlocks: LayoutBlock[] = [] + let lastSection: { sectionId: string; kind: string } | undefined + + // 非首页时补插表头;首页由流中的首个表头块自然带入。 + // 补插的表头也要占用高度,否则本页主体会溢出 headerHeightTotal。 + if (pageNumber > 1 && activeHeaders.length > 0) { + pageBlocks.push(...activeHeaders) + used += headerHeightTotal + } + + while (cursor < stream.length) { + const item = stream[cursor]! + const blockHeight = item.block.height + + // 块比整页还高:无法容纳,记录诊断并单独占一页(允许溢出) + if (blockHeight > bodyPerPage) { + if (pageBlocks.length === 0) { + pageBlocks.push(item.block) + lastSection = { sectionId: item.sectionId, kind: item.sectionKind } + cursor += 1 + used = blockHeight + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.LAYOUT_PAGE_OVERFLOW, + message: + `内容块高度 ${blockHeight.toFixed(2)}mm 超过单页可排高度 ` + + `${bodyPerPage.toFixed(2)}mm,该块将溢出页面`, + elementId: item.blockId, + }) + } + break + } + + // 组变更强制换页:本块(非表头/非本页首块)声明了 pageBreakBefore 时, + // 结束当前页,从新页开始输出本组。 + if (item.block.pageBreakBefore && pageBlocks.length > 0 && !hasOnlyHeaders(pageBlocks)) { + break + } + + // 组头与首行不分离(keepWithNext):放入本块前,预判下一块是否放得下。 + // 若放不下且当前页已有普通内容,则本块不进本页,留给下一页一起输出。 + if (item.block.keepWithNext) { + const nextItem = stream[cursor + 1] + if (nextItem) { + const nextHeight = nextItem.block.height + if ( + nextHeight <= bodyPerPage && + used + blockHeight + nextHeight > bodyPerPage + 1e-6 && + !hasOnlyHeaders(pageBlocks) + ) { + break + } + } + } + + // 组脚与末行不分离(keepWithPrevious):本块放不下当前页时, + // 回退本页最后一个普通块,让组脚与它同页输出。 + if (item.block.keepWithPrevious) { + if (used + blockHeight > bodyPerPage + 1e-6) { + const rollback = popLastOrdinaryBlock(pageBlocks) + if (rollback) { + used -= rollback.height + cursor -= 1 + } + break + } + } + + if (used + blockHeight > bodyPerPage + 1e-6) break + + pageBlocks.push(item.block) + lastSection = { sectionId: item.sectionId, kind: item.sectionKind } + used += blockHeight + cursor += 1 + + // 表头块本身始终渲染一次;只有声明了 repeatOnEachPage 才在后续页面补插 + if (item.block.kind === "header") { + if (item.block.repeatOnEachPage !== false) { + if (!activeHeaders.includes(item.block)) { + activeHeaders.push(item.block) + headerHeightTotal += item.block.height + } + } + continue + } + } + + pages.push( + buildPage( + pageNumber, + repeating, + pageBlocks, + geometry, + lastSection, + headerHeight, + footerHeight, + ), + ) + pageNumber += 1 + + if (pageBlocks.length === 0) { + // 防御:避免死循环 + diagnostics.push({ + severity: "error", + code: DIAGNOSTIC_CODES.LAYOUT_PAGE_OVERFLOW, + message: "分页未能推进,已中止以避免死循环", + }) + break + } + } + + return { pages, diagnostics, pageCount: pages.length } +} + +/** + * 判断当前页是否只有表头(或空)。 + * + * keepWithNext / pageBreakBefore 的分页回退只在"页上已有普通内容"时才需要; + * 若当前页只有表头(或尚无任何块),把组头放进来是安全的——它不会孤立, + * 因为组头本就需要与后续内容同页,而本页还有空间。 + */ +function hasOnlyHeaders(blocks: readonly LayoutBlock[]): boolean { + return blocks.every((block) => block.kind === "header") +} + +/** + * 从当前页的块列表中弹出最后一个"普通块"(非表头),并返回它。 + * + * 用于 keepWithPrevious(组脚与末行不分离):组脚放不下当前页时, + * 把上一块一起挪到下一页,保证组脚与它绑定的内容同页。 + * 返回 null 表示没有可回退的普通块(例如页上只有表头)。 + */ +function popLastOrdinaryBlock(blocks: LayoutBlock[]): LayoutBlock | null { + for (let i = blocks.length - 1; i >= 0; i -= 1) { + const block = blocks[i]! + if (block.kind !== "header") { + return blocks.splice(i, 1)[0] ?? null + } + } + return null +} + +/** 展平后的流项。 */ +interface StreamItem { + block: LayoutBlock + sectionId: string + sectionKind: string + blockId: string +} + +/** + * 把主体区域展平成有序块序列。 + * + * 不可拆分的区域整体作为一个块;可拆分区域按其 blocks 展开。 + */ +function flattenBlocks( + sections: readonly MeasuredSection[], + ctx: PaginationContext, + diagnostics: Diagnostic[], +): StreamItem[] { + const stream: StreamItem[] = [] + + for (const section of sections) { + if (section.blocks && section.blocks.length > 0) { + for (let i = 0; i < section.blocks.length; i += 1) { + const block = section.blocks[i]! + stream.push({ + block, + sectionId: section.sectionId, + sectionKind: section.kind, + blockId: `${section.sectionId}#${i}`, + }) + } + continue + } + + // 整块不可分:用一个渲染函数统一产出该区域的所有节点 + const elements = section.elements + const height = section.height + if (height <= 0 && elements.length === 0) continue + + if (!section.allowSplit && height > 0) { + // 不可拆分区域,作为单块 + } else if (height <= 0) { + continue + } + + stream.push({ + block: { + height, + render: (top: number) => renderElements(elements, top), + }, + sectionId: section.sectionId, + sectionKind: section.kind, + blockId: section.sectionId, + }) + + if (!section.allowSplit) continue + } + + return stream +} + +/** + * 把已测量元素转成布局节点(绝对坐标)。 + * + * @param top 该块在本页的起始 Y(绝对 mm) + */ +export function renderElements(elements: readonly MeasuredElement[], top: number): LayoutNode[] { + return elements.map((element) => toLayoutNode(element, top)) +} + +/** 把单个已测量元素转为布局节点。 */ +export function toLayoutNode(element: MeasuredElement, offsetY: number): LayoutNode { + const frame: Rect = { + x: element.frame.x, + y: element.frame.y + offsetY, + width: element.frame.width, + height: element.frame.height, + } + + const node: LayoutNode = { + id: element.element.id, + type: element.element.type, + frame, + style: element.style, + role: "element", + } + + // 内容按类型产出,Renderer 只负责画,不再计算 + if (element.element.type === "text" && element.wrapped) { + const content: TextContent = { + kind: "text", + text: element.text ?? "", + lines: element.wrapped.lines, + lineHeight: element.wrapped.lineHeight, + overflow: false, + } + node.content = content + } else if (element.element.type === "rect") { + node.content = { kind: "rect" } + } else if (element.element.type === "line") { + const direction = + (element.element.props?.direction as "horizontal" | "vertical" | undefined) ?? "horizontal" + const thickness = + (element.element.props?.thickness as number | undefined) ?? Math.max(0.1, element.frame.height) + node.content = { kind: "line", direction, thickness } + } else if (element.element.type === "image") { + node.content = { + kind: "image", + src: (element.element.props?.src as string | undefined) ?? "", + fit: (element.element.props?.fit as "contain" | "cover" | "fill" | "none" | undefined) ?? "contain", + } + } + + if (element.children.length > 0) { + node.children = element.children.map((child) => toLayoutNode(child, offsetY)) + } + + return node +} + +/** 构建一页。 */ +function buildPage( + pageNumber: number, + repeating: readonly MeasuredSection[], + blocks: readonly LayoutBlock[], + geometry: PageGeometry, + lastSection: { sectionId: string; kind: string } | undefined, + headerHeight: number, + footerHeight: number, +): LayoutPage { + const nodes: LayoutNode[] = [] + const sections: LayoutPageSection[] = [] + + // 页眉 + let cursorY = geometry.contentY + for (const section of repeating.filter((s) => s.kind === "page-header")) { + const rendered = renderElements(section.elements, cursorY) + nodes.push(...rendered) + sections.push({ + kind: section.kind, + sectionId: section.sectionId, + top: cursorY, + bottom: cursorY + section.height, + }) + cursorY += section.height + } + + // 主体 + const bodyTop = cursorY + const bodyBottom = geometry.contentY + geometry.contentHeight - footerHeight + let bodyCursor = bodyTop + + for (const block of blocks) { + const rendered = block.render(bodyCursor) + nodes.push(...rendered) + bodyCursor += block.height + } + + if (lastSection && blocks.length > 0) { + sections.push({ + kind: lastSection.kind, + sectionId: lastSection.sectionId, + top: bodyTop, + bottom: Math.min(bodyCursor, bodyBottom), + }) + } + + // 页脚:贴着内容区底部 + const footerTop = geometry.contentY + geometry.contentHeight - footerHeight + let footerCursor = footerTop + for (const section of repeating.filter((s) => s.kind === "page-footer")) { + const rendered = renderElements(section.elements, footerCursor) + nodes.push(...rendered) + sections.push({ + kind: section.kind, + sectionId: section.sectionId, + top: footerCursor, + bottom: footerCursor + section.height, + }) + footerCursor += section.height + } + + return { + width: geometry.width, + height: geometry.height, + pageNumber, + nodes, + sections, + } +} + +/** 计算区域测量高度并包装成可分发结构。 */ +export function toMeasuredSection(options: { + sectionId: string + kind: string + elements: MeasuredElement[] + repeatOnEachPage: boolean + allowSplit: boolean + blocks?: LayoutBlock[] +}): MeasuredSection { + return { + sectionId: options.sectionId, + kind: options.kind, + elements: options.elements, + height: contentHeight(options.elements), + repeatOnEachPage: options.repeatOnEachPage, + allowSplit: options.allowSplit, + ...(options.blocks ? { blocks: options.blocks } :{}), + } +} + +/** 判断布局节点是否为文本。 */ +export function isTextNode(node: LayoutNode): node is LayoutNode & { content: TextContent } { + return node.content?.kind === "text" +} + +/** 取节点的已解析样式(类型收窄辅助)。 */ +export function nodeStyle(node: LayoutNode): ResolvedStyle { + return node.style +} diff --git a/code/one-designer/packages/report-layout/src/system-fonts.ts b/code/one-designer/packages/report-layout/src/system-fonts.ts new file mode 100644 index 00000000..a11d656e --- /dev/null +++ b/code/one-designer/packages/report-layout/src/system-fonts.ts @@ -0,0 +1,122 @@ +/** + * 系统字体探测(对应设计文档 §9 的字体环境适配)。 + * + * 目标:在 Node / Electron 环境中给出一份"开箱可用"的字体来源列表, + * 使宿主不必硬编码字体路径。探测不到时静默跳过,由宿主决定是否告警。 + * + * 只做文件存在性判断,不加载字体内容——加载由 FontLibrary 负责。 + */ +import { existsSync } from "node:fs" +import { join } from "node:path" +import type { FontSource } from "./load-font.js" + +/** 一个候选字体:族名 + 相对于字体目录的文件名。 */ +interface FontCandidate { + family: string + file: string + fontIndex?: number +} + +/** + * Windows 候选字体。 + * + * 顺序即优先级:先拉丁字体,再中文回退字体。 + * 中文字体用于中英混排时按字符回退(汉字在拉丁字体里通常缺失)。 + */ +const WINDOWS_CANDIDATES: readonly FontCandidate[] = [ + { family: "Arial", file: "arial.ttf" }, + { family: "Times New Roman", file: "times.ttf" }, + { family: "Courier New", file: "cour.ttf" }, + { family: "Segoe UI", file: "segoeui.ttf" }, + { family: "SimSun", file: "simsun.ttc", fontIndex: 0 }, + { family: "SimHei", file: "simhei.ttf" }, + { family: "Microsoft YaHei", file: "msyh.ttc", fontIndex: 0 }, + { family: "DengXian", file: "Deng.ttf" }, + { family: "KaiTi", file: "simkai.ttf" }, + { family: "FangSong", file: "simfang.ttf" }, +] + +/** Linux 候选字体。 */ +const LINUX_CANDIDATES: readonly FontCandidate[] = [ + { family: "DejaVu Sans", file: "truetype/dejavu/DejaVuSans.ttf" }, + { family: "Liberation Sans", file: "truetype/liberation/LiberationSans-Regular.ttf" }, + { family: "Noto Sans CJK SC", file: "opentype/noto/NotoSansCJK-Regular.ttc", fontIndex: 0 }, + { family: "WenQuanYi Zen Hei", file: "truetype/wqy/wqy-zenhei.ttc", fontIndex: 0 }, +] + +/** macOS 候选字体。 */ +const MAC_CANDIDATES: readonly FontCandidate[] = [ + { family: "Helvetica", file: "Helvetica.ttc", fontIndex: 0 }, + { family: "PingFang SC", file: "PingFang.ttc", fontIndex: 0 }, + { family: "STHeiti", file: "STHeiti Light.ttc", fontIndex: 0 }, + { family: "Songti SC", file: "Songti.ttc", fontIndex: 0 }, +] + +/** 各平台的字体目录。 */ +function fontDirectories(): { dir: string; candidates: readonly FontCandidate[] }[] { + switch (process.platform) { + case "win32": { + const winDir = process.env.WINDIR ?? "C:\\Windows" + return [{ dir: join(winDir, "Fonts"), candidates: WINDOWS_CANDIDATES }] + } + case "darwin": + return [ + { dir: "/System/Library/Fonts", candidates: MAC_CANDIDATES }, + { dir: "/Library/Fonts", candidates: MAC_CANDIDATES }, + ] + default: + return [ + { dir: "/usr/share/fonts", candidates: LINUX_CANDIDATES }, + { dir: "/usr/local/share/fonts", candidates: LINUX_CANDIDATES }, + ] + } +} + +/** + * 探测系统上真实存在的字体。 + * + * @param options.only 只返回这些族名(可选),用于缩小范围 + */ +export function findSystemFontSources(options: { only?: readonly string[] } = {}): FontSource[] { + const wanted = options.only?.map((name) => name.toLowerCase()) + const found: FontSource[] = [] + const seen = new Set() + + for (const { dir, candidates } of fontDirectories()) { + for (const candidate of candidates) { + const key = candidate.family.toLowerCase() + if (seen.has(key)) continue + if (wanted && !wanted.includes(key)) continue + + const path = join(dir, candidate.file) + if (!existsSync(path)) continue + + seen.add(key) + found.push({ + family: candidate.family, + path, + ...(candidate.fontIndex !== undefined ? { fontIndex: candidate.fontIndex } :{}), + }) + } + } + + return found +} + +/** + * 默认字体来源。 + * + * 与 findSystemFontSources 相同,但名字更直白,适合直接传给 FontLibrary.loadAll。 + */ +export function defaultFontSources(): FontSource[] { + return findSystemFontSources() +} + +/** + * 推荐的字体回退链。 + * + * 中英混排时按此顺序查找能覆盖字符的字体。 + */ +export function recommendedFallbackFamilies(): string[] { + return ["SimSun", "SimHei", "Microsoft YaHei", "DengXian", "Noto Sans CJK SC", "WenQuanYi Zen Hei", "PingFang SC", "Songti SC"] +} diff --git a/code/one-designer/packages/report-layout/src/table.ts b/code/one-designer/packages/report-layout/src/table.ts new file mode 100644 index 00000000..49235a47 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/table.ts @@ -0,0 +1,308 @@ +/** + * 明细表展开(对应设计文档 §6.3 与 §3.3 的"表格展开"职责)。 + * + * 明细表是 V1 的特殊元素,负责把数据集展开成多行。单元格内的元素坐标 + * 相对于当前单元格。 + * + * 分页配合方式:产出"表头块 + 每行一个块"的块序列, + * 这样分页时行与行之间可以断开,而单行不会被拆散。 + */ +import type { Diagnostic, ReportElement, TableColumn, TableElement } from "@report/core" +import { DIAGNOSTIC_CODES, measureTableWidth } from "@report/core" +import type { EvalScope } from "@report/expression" +import { resolveBinding } from "@report/expression" +import type { LayoutBlock } from "./paginate.js" +import type { LayoutNode, TableColumnLayout, TableContent, TableRow } from "./layout-document.js" +import type { MeasureContext } from "./measure.js" +import { resolveElementStyle, measureElement } from "./measure.js" +import { wrapText } from "./text-wrap.js" + +/** 构建明细表的分页块序列。 */ +export function buildTableBlocks(options: { + element: ReportElement + ctx: MeasureContext + datasets: Map>> + parentWidth: number + path: string + diagnostics: Diagnostic[] +}): LayoutBlock[] { + const { element, ctx, datasets, parentWidth, path, diagnostics } = options + const table = element as TableElement + + const style = resolveElementStyle(element, ctx.document, diagnostics) + const tableWidth = element.frame.width > 0 ? element.frame.width : parentWidth + const tableX = element.frame.x + const tableTop = element.frame.y + + const datasetId = table.datasetId + const rows = datasets.get(datasetId) ?? [] + + if (!datasets.has(datasetId)) { + diagnostics.push({ + severity: "warning", + code: DIAGNOSTIC_CODES.DATA_DATASET_MISSING, + message: `明细表引用的数据集 "${datasetId}" 没有数据`, + elementId: element.id, + path: `${path}/datasetId`, + }) + } + + // 列布局:按声明宽度顺序排布;合计超出表格宽度时按比例压缩 + const declaredWidths = table.columns.map((column) => column.width) + const totalDeclared = declaredWidths.reduce((sum, width) => sum + width, 0) + const scale = totalDeclared > tableWidth && totalDeclared > 0 ? tableWidth / totalDeclared : 1 + + const columns: TableColumnLayout[] = [] + let cursorX = 0 + for (const column of table.columns) { + const width = column.width * scale + columns.push({ + id: column.id, + x: cursorX, + width, + align: column.align ?? style.align, + ...(column.header !== undefined ? { header: column.header } :{}), + }) + cursorX += width + } + + const headerHeight = table.headerHeight ?? 8 + const rowHeight = table.rowHeight ?? 7 + const rowHeightMode = table.rowHeightMode ?? "fixed" + // 表头始终存在;repeatHeaderOnEachPage 只决定"是否在后续页面重复", + // 不应影响首页是否绘制表头(早期实现把两者混为一谈,导致设为 false 时表头消失)。 + const repeatHeader = table.repeatHeaderOnEachPage !== false + const showGrid = table.showGrid !== false + + const blocks: LayoutBlock[] = [] + + // 表头块 + if (columns.length > 0) { + blocks.push({ + height: headerHeight, + kind: "header", + repeatOnEachPage: repeatHeader, + render: (top: number): LayoutNode[] => { + const content: TableContent = { + kind: "table", + rows: [], + columns, + showHeader: true, + headerHeight, + rowHeight, + showGrid, + } + return [ + { + id: `${element.id}#header`, + type: "table", + role: "table-header", + frame: { x: tableX, y: tableTop + top, width: tableWidth, height: headerHeight }, + style, + content, + }, + ] + }, + }) + } + + // 无数据占位 + if (rows.length === 0) { + const emptyText = table.emptyText ?? "无数据" + blocks.push({ + height: rowHeight, + render: (top: number): LayoutNode[] => { + const wrapped = wrapText(emptyText, { + maxWidth: tableWidth, + style, + measurer: ctx.measurer, + }) + return [ + { + id: `${element.id}#empty`, + type: "text", + role: "table-row", + frame: { x: tableX, y: tableTop + top, width: tableWidth, height: rowHeight }, + style: { ...style, align: "center" }, + content: { + kind: "text", + text: emptyText, + lines: wrapped.lines, + lineHeight: wrapped.lineHeight, + overflow: false, + }, + }, + ] + }, + }) + return blocks + } + + // 每行一个块,便于跨页拆分 + rows.forEach((row, index) => { + const scope: EvalScope = { + row, + params: ctx.renderContext.parameters, + index, + rowNumber: index + 1, + dataset: { rows, count: rows.length }, + } + + // 预先求值每列,同时计算动态行高 + const cells: { columnId: string; text: string; value?: unknown }[] = [] + let computedHeight = rowHeight + const wrappedCells = new Map() + + for (let c = 0; c < table.columns.length; c += 1) { + const column = table.columns[c]! + const layout = columns[c]! + const resolved = resolveCell(column, scope, ctx, element.id, `${path}/columns/${c}`) + diagnostics.push(...resolved.diagnostics) + + const innerWidth = Math.max(0, layout.width - style.padding.left - style.padding.right) + const wrapped = wrapText(resolved.text, { + maxWidth: innerWidth, + style, + measurer: ctx.measurer, + }) + wrappedCells.set(column.id, { lines: wrapped.lines, lineHeight: wrapped.lineHeight }) + + if (rowHeightMode === "auto" && wrapped.totalHeight > computedHeight) { + computedHeight = wrapped.totalHeight + style.padding.top + style.padding.bottom + } + + cells.push({ + columnId: column.id, + text: resolved.text, + ...(resolved.value !== undefined ? { value: resolved.value } :{}), + }) + } + + const tableRow: TableRow = { index, rowNumber: index + 1, cells, height: computedHeight } + const height = computedHeight + + blocks.push({ + height, + // 记录数据行索引,供分组按行身份选取块(不依赖数组下标) + rowIndex: index, + render: (top: number): LayoutNode[] => { + const content: TableContent = { + kind: "table", + rows: [tableRow], + columns, + showHeader: false, + headerHeight, + rowHeight, + showGrid, + } + const rowNode: LayoutNode = { + id: `${element.id}#row${index}`, + type: "table", + role: "table-row", + frame: { x: tableX, y: tableTop + top, width: tableWidth, height }, + style, + content, + } + + // 单元格内自由布局的子元素:坐标相对单元格左上角 + const cellChildren: LayoutNode[] = [] + for (let c = 0; c < table.columns.length; c += 1) { + const column = table.columns[c]! + if (!column.children?.length) continue + const layout = columns[c]! + for (const child of column.children) { + const measured = measureElement( + { + ...child, + frame: { + ...child.frame, + x: child.frame.x + layout.x, + y: child.frame.y, + }, + }, + layout.width, + ctx, + scope, + `${path}/columns/${c}`, + ) + diagnostics.push(...measured.diagnostics) + + const cellNode: LayoutNode = { + id: measured.element.id, + type: measured.element.type, + role: "table-cell", + frame: { + x: tableX, + y: tableTop + top, + width: measured.frame.width, + height: measured.frame.height, + }, + style: measured.style, + } + if (measured.text !== undefined) { + const wrapped = wrappedCells.get(column.id) + cellNode.content = { + kind: "text", + text: measured.text, + lines: wrapped?.lines ?? [measured.text], + lineHeight: wrapped?.lineHeight ?? style.fontSize, + overflow: false, + } + } + cellChildren.push(cellNode) + } + } + if (cellChildren.length > 0) rowNode.children = cellChildren + return [rowNode] + }, + }) + }) + + return blocks +} + +/** 求值一个单元格。 */ +function resolveCell( + column: TableColumn, + scope: EvalScope, + ctx: MeasureContext, + elementId: string, + path: string, +): { text: string; value: unknown; diagnostics: Diagnostic[] } { + // 优先使用列自身的 children(自由布局时由子元素承载内容) + if (column.children && column.children.length > 0 && !column.binding) { + return { text: "", value: undefined, diagnostics: [] } + } + + // 没有绑定时退回到表头文本,避免整列空白 + if (!column.binding) { + return { text: column.header ?? "", value: undefined, diagnostics: [] } + } + + const resolved = resolveBinding( + column.binding, + { scope, elementId, path: `${path}/binding` }, + ctx.cache, + ) + return { text: resolved.text, value: resolved.value, diagnostics: resolved.diagnostics } +} + +/** 计算表格在给定页高下需要多少行。 */ +export function estimateTableRowsPerPage( + rowHeight: number, + availableHeight: number, + headerHeight = 0, +): number { + if (rowHeight <= 0) return 0 + return Math.max(0, Math.floor((availableHeight - headerHeight) / rowHeight)) +} + +/** 判断元素是否为可展开的明细表。 */ +export function isExpandableTable(element: ReportElement): element is TableElement { + return element.type === "table" && Array.isArray((element as TableElement).columns) +} + +/** 计算表格声明宽度。 */ +export function declaredTableWidth(table: TableElement): number { + return measureTableWidth(table) +} diff --git a/code/one-designer/packages/report-layout/src/text-wrap.ts b/code/one-designer/packages/report-layout/src/text-wrap.ts new file mode 100644 index 00000000..6df4f182 --- /dev/null +++ b/code/one-designer/packages/report-layout/src/text-wrap.ts @@ -0,0 +1,308 @@ +/** + * 文本换行(对应设计文档 §3.3 的"换行"职责与 §9)。 + * + * 换行规则: + * - 优先在空白处断行(西文按词)。 + * - 中文可在任意字之间断行(无空格分词)。 + * - 标点不能出现在行首(避头点),常见的中文标点按此处理。 + * - 单个词超过行宽时强制断词,避免无限循环。 + * + * 换行结果完全由 TextMeasurer 决定,从而与具体字体环境解耦。 + */ +import type { ResolvedStyle } from "@report/core" +import type { TextMeasurer } from "./measurer.js" + +/** 不允许出现在行首的字符(避头点)。 */ +const NO_LINE_START = new Set([ + ",", "。", "、", ";", ":", "?", "!", ")", "】", "》", "」", "』", + "”", "’", "…", "—", "~", "·", "%", "‰", "℃", + ",", ".", ";", ":", "?", "!", ")", "]", "}", +]) + +/** 不允许出现在行尾的字符(避尾点)。 */ +const NO_LINE_END = new Set([ + "(", "【", "《", "「", "『", "“", "‘", + "(", "[", "{", +]) + +/** 换行选项。 */ +export interface WrapOptions { + /** 可用宽度(mm)。 */ + maxWidth: number + /** 样式,用于度量。 */ + style: ResolvedStyle + /** 度量器。 */ + measurer: TextMeasurer + /** 是否允许在词内断行(西文长词)。默认 true。 */ + breakLongWords?: boolean +} + +/** 换行结果。 */ +export interface WrapResult { + lines: string[] + /** 每行的宽度(mm),与 lines 一一对应。 */ + widths: number[] + /** 单行高度(mm)。 */ + lineHeight: number + /** 内容总高度(mm)。 */ + totalHeight: number + /** 是否发生了强制断词。 */ + brokeWords: boolean +} + +/** + * 把文本按可用宽度换行。 + * + * 输入中的显式换行符会被保留为硬换行。 + */ +export function wrapText(text: string, options: WrapOptions): WrapResult { + const { maxWidth, style, measurer, breakLongWords = true } = options + + const metrics = measurer.measure("M", style) + const lineHeight = metrics.height + + // 空文本仍占一行高度,保证空绑定元素有确定的排版高度 + if (text === "") { + return { lines: [""], widths: [0], lineHeight, totalHeight: lineHeight, brokeWords: false } + } + + const lines: string[] = [] + const widths: number[] = [] + let brokeWords = false + + // 硬换行先切分,再对每段做软换行 + const paragraphs = text.split(/\r\n|\r|\n/) + + for (const paragraph of paragraphs) { + if (paragraph === "") { + lines.push("") + widths.push(0) + continue + } + + const segments = segmentText(paragraph) + + let current = "" + let currentWidth = 0 + + const flush = (): void => { + lines.push(current) + widths.push(currentWidth) + current = "" + currentWidth = 0 + } + + for (let i = 0; i < segments.length; i += 1) { + const segment = segments[i]! + const segmentWidth = measureWidth(segment, style, measurer) + + // 空段(空白)直接累积,等待后续内容决定是否保留 + if (segment.trim() === "") { + if (current !== "") { + current += segment + currentWidth += segmentWidth + } + continue + } + + if (currentWidth + segmentWidth <= maxWidth) { + current += segment + currentWidth += segmentWidth + continue + } + + // 放不下:先结算当前行。 + // 两种避让规则都在"提交本行之前"处理,否则 flush 之后 current 已清空, + // 再判断就没有意义了。 + if (current !== "") { + if (isNoLineStart(segment) && current.length > 1) { + // 避头点:下一段不能出现在行首,把本行最后一个字符一起挪到下一行 + const lastChar = current[current.length - 1]! + const lastWidth = measureWidth(lastChar, style, measurer) + const head = current.slice(0, -1) + const headWidth = currentWidth - lastWidth + if (head.trim() !== "") { + lines.push(head) + widths.push(headWidth) + current = lastChar + currentWidth = lastWidth + } else { + flush() + } + } else if (isNoLineEnd(current)) { + // 避尾点:本行不能以「(」这类字符结尾,把它挪到下一行 + const lastChar = current[current.length - 1]! + const lastWidth = measureWidth(lastChar, style, measurer) + const head = current.slice(0, -1) + const headWidth = currentWidth - lastWidth + if (head.trim() !== "") { + lines.push(head) + widths.push(headWidth) + current = lastChar + currentWidth = lastWidth + } else { + flush() + } + } else { + flush() + } + } + + if (segmentWidth <= maxWidth) { + current = segment + currentWidth = segmentWidth + continue + } + + // 单个段就超宽:强制断词 + if (breakLongWords) { + brokeWords = true + let buffer = "" + let bufferWidth = 0 + for (const ch of segment) { + const chWidth = measureWidth(ch, style, measurer) + if (bufferWidth + chWidth > maxWidth && buffer !== "") { + lines.push(buffer) + widths.push(bufferWidth) + buffer = "" + bufferWidth = 0 + } + buffer += ch + bufferWidth += chWidth + } + current = buffer + currentWidth = bufferWidth + } else { + current = segment + currentWidth = segmentWidth + } + } + + if (current !== "" || segments.length === 0) { + // 行尾避头点:整段只剩一个标点时也要输出 + flush() + } + } + + if (lines.length === 0) { + lines.push("") + widths.push(0) + } + + const nonEmptyLines = lines.filter((line) => line !== "") + const totalHeight = lineHeight * (nonEmptyLines.length > 0 ? lines.length : 1) + + return { lines, widths, lineHeight, totalHeight, brokeWords } +} + +/** 判断字符是否不能出现在行首。 */ +export function isNoLineStart(text: string): boolean { + if (text === "") return false + return NO_LINE_START.has(text[0]!) +} + +/** 判断文本是否以不能出现在行尾的字符结尾。 */ +export function isNoLineEnd(text: string): boolean { + if (text === "") return false + return NO_LINE_END.has(text[text.length - 1]!) +} + +/** + * 把段落切成可断行的段。 + * + * 西文按词切分并保留词后空格;CJK 逐字切分。 + * 这样换行时既不会把一个英文单词拆开,也能在汉字间自由断行。 + */ +export function segmentText(paragraph: string): string[] { + const segments: string[] = [] + let buffer = "" + + const isCjk = (ch: string): boolean => { + const code = ch.codePointAt(0) ?? 0 + return ( + (code >= 0x2e80 && code <= 0x9fff) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xff00 && code <= 0xff60) || + (code >= 0x20000 && code <= 0x3fffd) + ) + } + + const flushBuffer = (): void => { + if (buffer !== "") { + segments.push(buffer) + buffer = "" + } + } + + for (let i = 0; i < paragraph.length; i += 1) { + const ch = paragraph[i]! + const code = ch.codePointAt(0) ?? 0 + + // 空格:结束当前段,并把空格一起带上。 + // 必须在这里 flush,否则纯西文整句会粘成一段、无法按词换行。 + // 若前面没有内容(行首空格),则空格自身成段,供避头点逻辑判断。 + if (ch === " " || ch === "\t") { + buffer += ch + flushBuffer() + continue + } + + if (isCjk(ch)) { + // CJK 字符单独成段,前面积累的西文先结算 + flushBuffer() + segments.push(ch) + continue + } + + // 代理对(emoji 等增补平面):两个码元一起处理 + if (code >= 0xd800 && code <= 0xdbff && i + 1 < paragraph.length) { + buffer += ch + paragraph[i + 1] + i += 1 + continue + } + + buffer += ch + } + + flushBuffer() + return segments +} + +/** 测量文本宽度,空串返回 0。 */ +function measureWidth(text: string, style: ResolvedStyle, measurer: TextMeasurer): number { + if (text === "") return 0 + return measurer.measure(text, style).width +} + +/** + * 计算文本在给定宽度下的高度(动态高度)。 + * + * 这是 §3.3 里"动态高度"的直接实现:表格行高、容器高度都依赖它。 + * maxHeight 缺省表示不限制高度,此时 overflow 恒为 false。 + */ +export function measureTextHeight( + text: string, + options: WrapOptions, + maxHeight?: number, +): { height: number; lineCount: number; overflow: boolean } { + const result = wrapText(text, options) + return { + height: result.totalHeight, + lineCount: result.lines.length, + overflow: maxHeight !== undefined && result.totalHeight > maxHeight + 1e-6, + } +} + +/** + * 计算换行后是否超出给定高度。 + */ +export function isTextOverflow( + text: string, + maxWidth: number, + maxHeight: number, + style: ResolvedStyle, + measurer: TextMeasurer, +): boolean { + const wrapped = wrapText(text, { maxWidth, style, measurer }) + return wrapped.totalHeight > maxHeight + 1e-6 +} diff --git a/code/one-designer/packages/report-layout/src/units.ts b/code/one-designer/packages/report-layout/src/units.ts new file mode 100644 index 00000000..549a684f --- /dev/null +++ b/code/one-designer/packages/report-layout/src/units.ts @@ -0,0 +1,57 @@ +/** + * 单位换算(对应设计文档 §7)。 + * + * 约定:文档内部统一使用 mm;px 只用于 Designer 显示层, + * pt 只在 PDF 等输出边界转换。 + * + * 本文件把该约定集中成一处可测试的换算表,避免各包各自散落常量。 + */ + +/** 1 英寸 = 25.4 毫米(精确值)。 */ +export const MM_PER_INCH = 25.4 + +/** CSS 参考像素:1 英寸 = 96px(CSS Values and Units Level 3)。 */ +export const PX_PER_INCH = 96 + +/** 排版点:1 英寸 = 72pt。 */ +export const PT_PER_INCH = 72 + +/** mm → px。 */ +export function mmToPx(mm: number): number { + return (mm / MM_PER_INCH) * PX_PER_INCH +} + +/** px → mm。 */ +export function pxToMm(px: number): number { + return (px / PX_PER_INCH) * MM_PER_INCH +} + +/** mm → pt。 */ +export function mmToPt(mm: number): number { + return (mm / MM_PER_INCH) * PT_PER_INCH +} + +/** pt → mm。 */ +export function ptToMm(pt: number): number { + return (pt / PT_PER_INCH) * MM_PER_INCH +} + +/** pt → px。 */ +export function ptToPx(pt: number): number { + return (pt / PT_PER_INCH) * PX_PER_INCH +} + +/** px → pt。 */ +export function pxToPt(px: number): number { + return (px / PX_PER_INCH) * PT_PER_INCH +} + +/** + * 字体的 em 尺寸换算:pt → mm。 + * + * 文档里的 fontSize 以 pt 表示(排版惯例),而 frame 以 mm 表示, + * 因此测量时统一把字号换算成 mm 再参与几何计算。 + */ +export function fontSizeToMm(fontSizePt: number): number { + return ptToMm(fontSizePt) +} diff --git a/code/one-designer/packages/report-layout/tests/font.test.ts b/code/one-designer/packages/report-layout/tests/font.test.ts new file mode 100644 index 00000000..e18ab8d2 --- /dev/null +++ b/code/one-designer/packages/report-layout/tests/font.test.ts @@ -0,0 +1,243 @@ +/** + * 字体解析与真实度量测试(对应 §14 的"中文字体、长文本"测试)。 + * + * 依赖系统字体,缺少字体时相关用例自动跳过,保证在无字体环境仍可运行。 + */ +import { describe, expect, it } from "vitest" +import { existsSync } from "node:fs" +import { loadFontFile, FontLibrary, parseFontBuffer } from "../src/load-font.js" +import { createFontMeasurer } from "../src/font-measurer.js" +import { DEFAULT_RESOLVED_STYLE, type ResolvedStyle } from "@report/core" + +const ARIAL = "C:\\Windows\\Fonts\\arial.ttf" +const SIMHEI = "C:\\Windows\\Fonts\\simhei.ttf" +const MSYH = "C:\\Windows\\Fonts\\msyh.ttc" + +const hasArial = existsSync(ARIAL) +const hasSimHei = existsSync(SIMHEI) +const hasMsyh = existsSync(MSYH) + +function style(overrides: Partial = {}): ResolvedStyle { + return { ...DEFAULT_RESOLVED_STYLE, fontSize: 10, ...overrides } +} + +describe("FontLibrary 加载", () => { + it("加载失败时记录而不抛异常", () => { + const library = new FontLibrary() + const ok = library.load({ family: "Missing", path: "C:\\nope\\nope.ttf" }) + expect(ok).toBe(false) + expect(library.loadFailures()).toHaveLength(1) + expect(library.size).toBe(0) + }) + + it("clear 清空字体与失败记录", () => { + const library = new FontLibrary() + library.load({ family: "Missing", path: "C:\\nope\\nope.ttf" }) + library.clear() + expect(library.size).toBe(0) + expect(library.loadFailures()).toHaveLength(0) + }) +}) + +describe.skipIf(!hasArial)("Arial 度量", () => { + const font = loadFontFile(ARIAL) + + it("解析出正确的字体元信息", () => { + expect(font.metrics.familyName).toBe("Arial") + expect(font.metrics.unitsPerEm).toBe(2048) + expect(font.metrics.numGlyphs).toBeGreaterThan(1000) + }) + + it("比例字体的宽度各不相同", () => { + const i = font.charAdvance("i".codePointAt(0)!)! + const w = font.charAdvance("W".codePointAt(0)!)! + expect(i).toBeLessThan(w) + expect(i).toBeCloseTo(0.2222, 3) + expect(w).toBeCloseTo(0.9438, 3) + }) + + it("不覆盖中文(返回 null 而非 .notdef 宽度)", () => { + // 关键正确性检查:.notdef 本身有 advance,直接返回会把缺字当正常字符 + expect(font.hasGlyph("中".codePointAt(0)!)).toBe(false) + expect(font.charAdvance("中".codePointAt(0)!)).toBeNull() + }) + + it("覆盖 ASCII", () => { + for (const ch of ["A", "z", "0", " ", ".", ","]) { + expect(font.hasGlyph(ch.codePointAt(0)!)).toBe(true) + } + }) +}) + +describe.skipIf(!hasSimHei)("SimHei 中文字体度量", () => { + const font = loadFontFile(SIMHEI) + + it("解析出中文字体族名", () => { + expect(font.metrics.familyName).toBe("SimHei") + }) + + it("汉字宽度为全角 1.0 em", () => { + for (const ch of ["中", "文", "报", "表"]) { + expect(font.charAdvance(ch.codePointAt(0)!)).toBeCloseTo(1.0, 4) + } + }) + + it("中文标点为全角", () => { + expect(font.charAdvance(",".codePointAt(0)!)).toBeCloseTo(1.0, 4) + }) + + it("拉丁字符为半角", () => { + expect(font.charAdvance("A".codePointAt(0)!)).toBeCloseTo(0.5, 4) + }) + + it("覆盖 GB 常用汉字", () => { + for (const ch of ["销", "售", "单", "金", "额", "数", "量"]) { + expect(font.hasGlyph(ch.codePointAt(0)!)).toBe(true) + } + }) +}) + +describe.skipIf(!hasMsyh)("TTC 字体集合", () => { + it("可从集合中按索引取字体", () => { + const font0 = loadFontFile(MSYH, { fontIndex: 0 }) + expect(font0.metrics.unitsPerEm).toBeGreaterThan(0) + expect(font0.metrics.familyName).toBeTruthy() + }) + + it("索引越界时报错", () => { + expect(() => loadFontFile(MSYH, { fontIndex: 99 })).toThrow(/越界/) + }) +}) + +describe("parseFontBuffer 容错", () => { + it("拒绝非字体数据", () => { + const junk = Buffer.from("this is definitely not a font file") + expect(() => parseFontBuffer(junk)).toThrow() + }) + + it("拒绝过短的数据", () => { + expect(() => parseFontBuffer(Buffer.from([0, 1, 0, 0]))).toThrow() + }) +}) + +describe.skipIf(!hasArial || !hasSimHei)("字体库与度量器协作", () => { + function buildLibrary(): FontLibrary { + const library = new FontLibrary() + library.load({ family: "Arial", path: ARIAL }) + library.load({ family: "SimHei", path: SIMHEI }) + return library + } + + it("按字体栈解析", () => { + const library = buildLibrary() + const hit = library.resolveStack('"NoSuchFont", SimHei, sans-serif') + expect(hit?.family).toBe("SimHei") + }) + + it("全部未命中时返回 undefined", () => { + const library = buildLibrary() + expect(library.resolveStack("NoSuchFont")).toBeUndefined() + }) + + it("度量纯英文文本", () => { + const library = buildLibrary() + const measurer = createFontMeasurer({ library }) + const metrics = measurer.measure("Hello", style({ fontFamily: "Arial", fontSize: 10 })) + // 10pt 约 3.528mm;"Hello" 在 Arial 下约 2.6em + expect(metrics.width).toBeGreaterThan(6) + expect(metrics.width).toBeLessThan(12) + }) + + it("度量纯中文文本", () => { + const library = buildLibrary() + const measurer = createFontMeasurer({ library }) + const metrics = measurer.measure("中文报表", style({ fontFamily: "SimHei", fontSize: 10 })) + const em = (10 / 72) * 25.4 + // 4 个全角字符 = 4em + expect(metrics.width).toBeCloseTo(4 * em, 3) + }) + + it("中英混排按字符回退到 CJK 字体", () => { + const library = buildLibrary() + const fallbacks: string[] = [] + const measurer = createFontMeasurer({ + library, + fallbackFamilies: ["SimHei"], + onFallback: (e) => fallbacks.push(e.char), + }) + const metrics = measurer.measure("A中", style({ fontFamily: "Arial", fontSize: 10 })) + const em = (10 / 72) * 25.4 + // A(0.667) + 中(1.0) = 1.667em + expect(metrics.width).toBeCloseTo(1.667 * em, 2) + expect(fallbacks).toContain("中") + }) + + it("缺失字形提高 fallback 标记", () => { + const library = buildLibrary() + const measurer = createFontMeasurer({ library }) + // 用 Arial 度量汉字,且无 CJK 回退 + const metrics = measurer.measure("中", style({ fontFamily: "Arial" })) + expect(metrics.fallback).toBe(true) + }) + + it("完整覆盖时 fallback 为 false", () => { + const library = buildLibrary() + const measurer = createFontMeasurer({ library }) + const metrics = measurer.measure("Hello", style({ fontFamily: "Arial" })) + expect(metrics.fallback).toBe(false) + }) + + it("行高与字号成正比", () => { + const library = buildLibrary() + const measurer = createFontMeasurer({ library }) + const a = measurer.measure("x", style({ fontFamily: "Arial", fontSize: 10 })) + const b = measurer.measure("x", style({ fontFamily: "Arial", fontSize: 20 })) + expect(b.height).toBeCloseTo(a.height * 2, 4) + }) + + it("字体栈中首选命中时不回退", () => { + const library = buildLibrary() + const measurer = createFontMeasurer({ library, fallbackFamilies: ["SimHei"] }) + const resolved = measurer.resolver.resolve("Arial, SimHei") + expect(resolved.resolved).toBe("Arial") + expect(resolved.fallback).toBe(false) + }) + + it("字体栈全部未命中时标记回退", () => { + const library = buildLibrary() + const measurer = createFontMeasurer({ library }) + const resolved = measurer.resolver.resolve("Nonexistent") + expect(resolved.fallback).toBe(true) + }) + + it("长文本宽度随字符数线性增长", () => { + const library = buildLibrary() + const measurer = createFontMeasurer({ library }) + const short = measurer.measure("中".repeat(10), style({ fontFamily: "SimHei" })) + const long = measurer.measure("中".repeat(100), style({ fontFamily: "SimHei" })) + expect(long.width).toBeCloseTo(short.width * 10, 3) + }) +}) + +describe("估算度量器(无字体环境)", () => { + it("全角字符为 1em", async () => { + const { createEstimateMeasurer } = await import("../src/measurer.js") + const measurer = createEstimateMeasurer() + const em = (10 / 72) * 25.4 + expect(measurer.measure("中", style()).width).toBeCloseTo(em, 4) + }) + + it("半角字符为 0.5em 附近", async () => { + const { createEstimateMeasurer } = await import("../src/measurer.js") + const measurer = createEstimateMeasurer() + const em = (10 / 72) * 25.4 + const width = measurer.measure("n", style()).width + expect(width).toBeGreaterThan(0.4 * em) + expect(width).toBeLessThan(0.7 * em) + }) + + it("始终标记 fallback", async () => { + const { createEstimateMeasurer } = await import("../src/measurer.js") + expect(createEstimateMeasurer().measure("x", style()).fallback).toBe(true) + }) +}) diff --git a/code/one-designer/packages/report-layout/tests/group.test.ts b/code/one-designer/packages/report-layout/tests/group.test.ts new file mode 100644 index 00000000..9e7dcdc4 --- /dev/null +++ b/code/one-designer/packages/report-layout/tests/group.test.ts @@ -0,0 +1,560 @@ +/** + * 分组测试(对应设计文档 §16 阶段 3 与 §6.1 的分组区域)。 + * + * 分组语义:相邻行键值相同即同组,不改变行顺序。 + */ +import { describe, expect, it } from "vitest" +import { + createDocument, + createTableElement, + type ReportDocument, + type ReportSection, +} from "@report/core" +import { createEstimateMeasurer, createPassthroughFontResolver } from "../src/measurer.js" +import { layoutDocument } from "../src/engine.js" +import { computeGroups, collectGroupLevels, isSameGroupKey } from "../src/group.js" +import type { GroupLevelSpec } from "../src/group.js" +import { walkLayoutNodes } from "../src/layout-document.js" +import type { RenderContext } from "../src/context.js" + +const measurer = createEstimateMeasurer() +const fontResolver = createPassthroughFontResolver() + +async function layout(doc: ReportDocument, context: RenderContext) { + return layoutDocument(doc, context, { textMeasurer: measurer, fontResolver }) +} + +const ROWS = [ + { region: "华北", city: "北京", amount: 100 }, + { region: "华北", city: "天津", amount: 200 }, + { region: "华东", city: "上海", amount: 300 }, + { region: "华东", city: "杭州", amount: 400 }, + { region: "华南", city: "广州", amount: 500 }, +] + +function baseDoc(): ReportDocument { + const doc = createDocument({ name: "分组报表" }) + doc.datasets = { sales: { id: "sales" } } + doc.sections.find((s) => s.kind === "detail")!.children.push( + createTableElement("tbl", { x: 0, y: 0, width: 180, height: 20 }, "sales", [ + { id: "c1", width: 60, header: "地区", binding: { field: "region" } }, + { id: "c2", width: 60, header: "城市", binding: { field: "city" } }, + { id: "c3", width: 60, header: "金额", binding: { field: "amount" }, align: "right" }, + ]), + ) + return doc +} + +/** 插入一个分组区域。 */ +function addGroupSection( + doc: ReportDocument, + kind: "group-header" | "group-footer", + children: ReportSection["children"], + extra: Partial = {}, +): ReportSection { + const section: ReportSection = { + id: `grp-${kind}`, + kind, + children, + groupExpression: "row.region", + groupLevel: 1, + ...extra, + } + // 插在 detail 之前(组头)或之后(组脚) + const detailIndex = doc.sections.findIndex((s) => s.kind === "detail") + const insertAt = kind === "group-header" ? detailIndex : detailIndex + 1 + doc.sections.splice(insertAt, 0, section) + return section +} + +describe("isSameGroupKey", () => { + it("相同标量为同组", () => { + expect(isSameGroupKey("a", "a")).toBe(true) + expect(isSameGroupKey(1, 1)).toBe(true) + }) + + it("不同标量不同组", () => { + expect(isSameGroupKey("a", "b")).toBe(false) + expect(isSameGroupKey(1, 2)).toBe(false) + }) + + it("数字与字符串等价(避免 1 与 '1' 被拆成两组)", () => { + expect(isSameGroupKey(1, "1")).toBe(true) + }) + + it("null 与 undefined 视为同组", () => { + expect(isSameGroupKey(null, undefined)).toBe(true) + expect(isSameGroupKey(null, null)).toBe(true) + expect(isSameGroupKey(null, 0)).toBe(false) + }) +}) + +describe("computeGroups", () => { + it("按相邻行切分", () => { + const levels: GroupLevelSpec[] = [{ level: 1, expression: "row.region" }] + const tree = computeGroups(ROWS, levels, []) + expect(tree.groups).toHaveLength(3) + expect(tree.groups[0]?.key).toBe("华北") + expect(tree.groups[1]?.key).toBe("华东") + expect(tree.groups[2]?.key).toBe("华南") + }) + + it("记录每组的行区间", () => { + const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], []) + expect(tree.groups[0]?.start).toBe(0) + expect(tree.groups[0]?.end).toBe(2) + expect(tree.groups[1]?.start).toBe(2) + expect(tree.groups[1]?.end).toBe(4) + expect(tree.groups[2]?.start).toBe(4) + expect(tree.groups[2]?.end).toBe(5) + }) + + it("每组携带该组行数据", () => { + const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], []) + expect(tree.groups[0]?.rows).toHaveLength(2) + expect(tree.groups[0]?.rows[0]?.city).toBe("北京") + }) + + it("标记首组与末组", () => { + const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], []) + expect(tree.groups[0]?.isFirst).toBe(true) + expect(tree.groups[0]?.isLast).toBe(false) + expect(tree.groups[2]?.isLast).toBe(true) + }) + + it("相同键不相邻时视为不同组(不排序)", () => { + const rows = [ + { region: "A" }, + { region: "B" }, + { region: "A" }, + ] + const tree = computeGroups(rows, [{ level: 1, expression: "row.region" }], []) + // 相邻切分:A / B / A 共 3 组 + expect(tree.groups).toHaveLength(3) + }) + + it("空数据集不产生分组", () => { + const tree = computeGroups([], [{ level: 1, expression: "row.region" }], []) + expect(tree.groups).toHaveLength(0) + }) + + it("无分组定义时不产生分组", () => { + const tree = computeGroups(ROWS, [], []) + expect(tree.groups).toHaveLength(0) + }) + + it("坏表达式产生诊断而非抛异常", () => { + const diagnostics: import("@report/core").Diagnostic[] = [] + const tree = computeGroups(ROWS, [{ level: 1, expression: "row." }], diagnostics) + expect(tree.groups).toHaveLength(0) + expect(diagnostics.some((d) => d.code === "expression.parse-error")).toBe(true) + }) + + it("支持多级分组", () => { + const rows = [ + { region: "华北", city: "北京" }, + { region: "华北", city: "北京" }, + { region: "华北", city: "天津" }, + { region: "华东", city: "上海" }, + ] + const tree = computeGroups( + rows, + [ + { level: 1, expression: "row.region" }, + { level: 2, expression: "row.city" }, + ], + [], + ) + // L1: 华北(0-3), 华东(3-4);L2: 北京(0-2), 天津(2-3), 上海(3-4) + const l1 = tree.groups.filter((g) => g.level === 1) + const l2 = tree.groups.filter((g) => g.level === 2) + expect(l1).toHaveLength(2) + expect(l2).toHaveLength(3) + }) + + it("多级分组中各级区间相互独立", () => { + const rows = [ + { region: "A", city: "x" }, + { region: "A", city: "y" }, + { region: "B", city: "z" }, + ] + const tree = computeGroups( + rows, + [ + { level: 1, expression: "row.region" }, + { level: 2, expression: "row.city" }, + ], + [], + ) + const l2 = tree.groups.filter((g) => g.level === 2) + expect(l2[0]?.rows).toHaveLength(1) + expect(l2[1]?.rows).toHaveLength(1) + }) + + it("分组结果按出现位置排序", () => { + const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], []) + const starts = tree.groups.map((g) => g.start) + expect([...starts].sort((a, b) => a - b)).toEqual(starts) + }) +}) + +describe("collectGroupLevels", () => { + it("从区域收集分组级别", () => { + const doc = baseDoc() + addGroupSection(doc, "group-header", []) + addGroupSection(doc, "group-footer", []) + const levels = collectGroupLevels(doc.sections, []) + expect(levels).toHaveLength(1) + expect(levels[0]?.expression).toBe("row.region") + expect(levels[0]?.headerSection).toBeDefined() + expect(levels[0]?.footerSection).toBeDefined() + }) + + it("缺少 groupExpression 时产生诊断", () => { + const diagnostics: import("@report/core").Diagnostic[] = [] + const section: ReportSection = { + id: "g1", + kind: "group-header", + children: [], + } + collectGroupLevels([section], diagnostics) + expect(diagnostics.some((d) => d.message.includes("缺少 groupExpression"))).toBe(true) + }) + + it("同级别表达式不一致时告警", () => { + const diagnostics: import("@report/core").Diagnostic[] = [] + const sections: ReportSection[] = [ + { id: "h", kind: "group-header", children: [], groupExpression: "row.a", groupLevel: 1 }, + { id: "f", kind: "group-footer", children: [], groupExpression: "row.b", groupLevel: 1 }, + ] + collectGroupLevels(sections, diagnostics) + expect(diagnostics.some((d) => d.message.includes("不同的"))).toBe(true) + }) + + it("多级别按 level 升序返回", () => { + const sections: ReportSection[] = [ + { id: "h2", kind: "group-header", children: [], groupExpression: "row.b", groupLevel: 2 }, + { id: "h1", kind: "group-header", children: [], groupExpression: "row.a", groupLevel: 1 }, + ] + const levels = collectGroupLevels(sections, []) + expect(levels.map((l) => l.level)).toEqual([1, 2]) + }) +}) + +describe("分组布局输出", () => { + it("组头渲染到输出中", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-header", [ + { + id: "gh", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 8 }, + binding: { expression: "'地区:' + row.region" }, + }, + ]) + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const texts: string[] = [] + walkLayoutNodes(result, (node) => { + if (node.content?.kind === "text") texts.push(node.content.text) + }) + // 三个地区各输出一次组头 + expect(texts.filter((t) => t.startsWith("地区:"))).toHaveLength(3) + expect(texts).toContain("地区:华北") + expect(texts).toContain("地区:华东") + expect(texts).toContain("地区:华南") + }) + + it("组脚按组聚合(sum)", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-footer", [ + { + id: "gf", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 8 }, + binding: { expression: "sum(group.amount) + ' / ' + group.count + ' 笔'" }, + }, + ]) + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const texts: string[] = [] + walkLayoutNodes(result, (node) => { + if (node.content?.kind === "text") texts.push(node.content.text) + }) + // 华北 100+200=300(2 笔),华东 300+400=700(2 笔),华南 500(1 笔) + expect(texts).toContain("300 / 2 笔") + expect(texts).toContain("700 / 2 笔") + expect(texts).toContain("500 / 1 笔") + }) + + it("组脚可用 avg 聚合", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-footer", [ + { + id: "gf", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 8 }, + binding: { expression: "avg(group.amount)" }, + }, + ]) + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + const texts: string[] = [] + walkLayoutNodes(result, (node) => { + if (node.content?.kind === "text") texts.push(node.content.text) + }) + // 华北均值 150,华东 350,华南 500 + expect(texts).toContain("150") + expect(texts).toContain("350") + }) + + it("组脚可访问 group.key", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-footer", [ + { + id: "gf", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 8 }, + binding: { expression: "string(group.key)" }, + }, + ]) + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + const texts: string[] = [] + walkLayoutNodes(result, (node) => { + if (node.content?.kind === "text") texts.push(node.content.text) + }) + expect(texts).toContain("华北") + expect(texts).toContain("华南") + }) + + it("详细行全部保留(分组不丢行)", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-header", [ + { id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组" } }, + ]) + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + let rowCount = 0 + walkLayoutNodes(result, (node) => { + if (node.role === "table-row" && node.content?.kind === "table") rowCount += 1 + }) + expect(rowCount).toBe(ROWS.length) + }) + + it("分组后行顺序不变", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-header", [ + { id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组" } }, + ]) + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const cities: string[] = [] + walkLayoutNodes(result, (node) => { + if (node.content?.kind === "table" && node.role === "table-row") { + const cell = node.content.rows[0]?.cells[1] + if (cell) cities.push(cell.text) + } + }) + expect(cities).toEqual(["北京", "天津", "上海", "杭州", "广州"]) + }) + + it("组头在详细行之前、组脚之后", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-header", [ + { id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 10 }, props: { text: "组头" } }, + ]) + addGroupSection(doc, "group-footer", [ + { id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 10 }, props: { text: "组脚" } }, + ]) + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const order: string[] = [] + walkLayoutNodes(result, (node) => { + if (node.id === "gh") order.push("H") + if (node.id === "gf") order.push("F") + if (node.role === "table-row") order.push("R") + }) + // 期望:H R R F H R R F H R F(3 组) + expect(order.join("")).toBe("HRRFHRRFHRF") + }) + + it("无数据时不输出分组区域", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-footer", [ + { id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组脚" } }, + ]) + const result = await layout(doc, { data: { sales: [] }, parameters: {} }) + const texts: string[] = [] + walkLayoutNodes(result, (node) => { + if (node.content?.kind === "text") texts.push(node.content.text) + }) + expect(texts).not.toContain("组脚") + expect(result.diagnostics.some((d) => d.code === "data.dataset-missing")).toBe(true) + }) + + it("没有分组定义时行为与之前一致", async () => { + const doc = baseDoc() + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + let rowCount = 0 + walkLayoutNodes(result, (node) => { + if (node.role === "table-row") rowCount += 1 + }) + expect(rowCount).toBe(ROWS.length) + }) + + it("表头仍每页重复(分组不影响)", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-header", [ + { id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 6 }, props: { text: "组" } }, + ]) + // 用大量数据迫使多页 + const manyRows = Array.from({ length: 200 }, (_, i) => ({ + region: `地区${Math.floor(i / 20)}`, + city: `城市${i}`, + amount: i, + })) + const result = await layout(doc, { data: { sales: manyRows }, parameters: {} }) + expect(result.pages.length).toBeGreaterThan(1) + for (const page of result.pages) { + expect(page.nodes.some((n) => n.role === "table-header")).toBe(true) + } + }) + + it("分组跨页时组脚仍输出", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-footer", [ + { + id: "gf", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 8 }, + binding: { expression: "sum(group.amount)" }, + }, + ]) + const manyRows = Array.from({ length: 200 }, (_, i) => ({ + region: `地区${Math.floor(i / 20)}`, + city: `城市${i}`, + amount: 1, + })) + const result = await layout(doc, { data: { sales: manyRows }, parameters: {} }) + + const totals: string[] = [] + walkLayoutNodes(result, (node) => { + if (node.id === "gf" && node.content?.kind === "text") totals.push(node.content.text) + }) + // 10 个组,每组 20 行 × 1 = 20 + expect(totals).toHaveLength(10) + expect(totals.every((t) => t === "20")).toBe(true) + }) + + it("组头不与组内首行分离(keepWithNext)", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-header", [ + { id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组头" } }, + ]) + // 大量行迫使多页;每个组头后面一定紧跟数据行 + const manyRows = Array.from({ length: 200 }, (_, i) => ({ + region: `地区${Math.floor(i / 20)}`, + city: `城市${i}`, + amount: i, + })) + const result = await layout(doc, { data: { sales: manyRows }, parameters: {} }) + + // 逐页检查:任何一页里,组头 gh 之后必然存在至少一个数据行 + for (const page of result.pages) { + const ids: string[] = [] + walkLayoutNodes({ ...result, pages: [page] }, (node) => { + if (node.id === "gh") ids.push("H") + else if (node.role === "table-row") ids.push("R") + }) + // 若本页有组头,则其后必须有数据行(组头不孤立) + const headerPos = ids.indexOf("H") + if (headerPos >= 0) { + expect(ids.slice(headerPos + 1)).toContain("R") + } + } + }) + + it("组脚不与组内末行分离(keepWithPrevious)", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-footer", [ + { id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组脚" } }, + ]) + const manyRows = Array.from({ length: 200 }, (_, i) => ({ + region: `地区${Math.floor(i / 20)}`, + city: `城市${i}`, + amount: i, + })) + const result = await layout(doc, { data: { sales: manyRows }, parameters: {} }) + + // 逐页检查:任何一页里,组脚 gf 之前必然存在数据行(组脚不孤立) + for (const page of result.pages) { + const ids: string[] = [] + walkLayoutNodes({ ...result, pages: [page] }, (node) => { + if (node.id === "gf") ids.push("F") + else if (node.role === "table-row") ids.push("R") + }) + const footerPos = ids.lastIndexOf("F") + if (footerPos >= 0) { + expect(ids.slice(0, footerPos)).toContain("R") + } + } + }) + + it("组脚不丢失(跨页后每组的组脚都在)", async () => { + const doc = baseDoc() + addGroupSection(doc, "group-footer", [ + { id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组脚" } }, + ]) + const manyRows = Array.from({ length: 60 }, (_, i) => ({ + region: `地区${Math.floor(i / 6)}`, + city: `城市${i}`, + amount: 1, + })) + const result = await layout(doc, { data: { sales: manyRows }, parameters: {} }) + + const footers: string[] = [] + walkLayoutNodes(result, (node) => { + if (node.id === "gf" && node.content?.kind === "text") footers.push(node.content.text) + }) + // 10 个组,每个组的组脚都必须出现一次 + expect(footers).toHaveLength(10) + expect(footers.every((t) => t === "组脚")).toBe(true) + }) + + it("pageBreakBefore 使每组独占一页起始", async () => { + const doc = baseDoc() + addGroupSection( + doc, + "group-header", + [{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组头" } }], + { pageBreakBefore: true }, + ) + // 3 组,每组若干行,组头声明 pageBreakBefore + const rows = Array.from({ length: 30 }, (_, i) => ({ + region: `地区${Math.floor(i / 10)}`, + city: `城市${i}`, + amount: i, + })) + const result = await layout(doc, { data: { sales: rows }, parameters: {} }) + + // 每个组头应落在其所在页的主体顶部(即该页第一个主体块) + const pageHeaders: string[][] = [] + for (const page of result.pages) { + const ids: string[] = [] + walkLayoutNodes({ ...result, pages: [page] }, (node) => { + if (node.id === "gh") ids.push("H") + else if (node.role === "table-row") ids.push("R") + else if (node.role === "table-header") ids.push("T") + }) + pageHeaders.push(ids) + } + + // 至少应有多页;每个含组头的页,组头应是首个非表头块 + const pagesWithHeader = pageHeaders.filter((ids) => ids.includes("H")) + expect(pagesWithHeader.length).toBe(3) + for (const ids of pagesWithHeader) { + // 去掉前导表头后,第一个主体块应是组头 + const firstBody = ids.find((x) => x !== "T") + expect(firstBody).toBe("H") + } + }) +}) diff --git a/code/one-designer/packages/report-layout/tests/pagination.test.ts b/code/one-designer/packages/report-layout/tests/pagination.test.ts new file mode 100644 index 00000000..a3eb819c --- /dev/null +++ b/code/one-designer/packages/report-layout/tests/pagination.test.ts @@ -0,0 +1,558 @@ +/** + * 分页与布局引擎测试(对应设计文档 §14 的"页眉、页脚和多页分页测试"、 + * "固定元素和明细表的布局快照测试")。 + */ +import { describe, expect, it } from "vitest" +import { + createDocument, + createTableElement, + type ReportDocument, +} from "@report/core" +import { createEstimateMeasurer, createPassthroughFontResolver } from "../src/measurer.js" +import { layoutDocument } from "../src/engine.js" +import { computePageGeometry } from "../src/paginate.js" +import { countLayoutNodes, walkLayoutNodes } from "../src/layout-document.js" +import type { RenderContext } from "../src/context.js" + +const measurer = createEstimateMeasurer() +const fontResolver = createPassthroughFontResolver() + +function emptyContext(parameters: Record = {}): RenderContext { + return { data: {}, parameters } +} + +async function layout(doc: ReportDocument, context = emptyContext()) { + return layoutDocument(doc, context, { textMeasurer: measurer, fontResolver }) +} + +/** 一份含页眉、明细文本、页脚的文档。 */ +function documentWith(configure: (doc: ReportDocument) => void): ReportDocument { + const doc = createDocument({ name: "测试报表" }) + configure(doc) + return doc +} + +describe("页面几何", () => { + it("A4 纵向尺寸正确", () => { + const doc = createDocument({ page: { paper: "A4", orientation: "portrait" } }) + const geometry = computePageGeometry(doc) + expect(geometry.width).toBe(210) + expect(geometry.height).toBe(297) + // 默认边距 15mm + expect(geometry.contentWidth).toBe(180) + expect(geometry.contentHeight).toBe(267) + }) + + it("横向交换宽高", () => { + const doc = createDocument({ page: { paper: "A4", orientation: "landscape" } }) + const geometry = computePageGeometry(doc) + expect(geometry.width).toBe(297) + expect(geometry.height).toBe(210) + }) + + it("A5 尺寸正确", () => { + const doc = createDocument({ page: { paper: "A5" } }) + const geometry = computePageGeometry(doc) + expect(geometry.width).toBe(148) + expect(geometry.height).toBe(210) + }) + + it("自定义纸张生效", () => { + const doc = createDocument({ + page: { paper: "custom", customSize: { width: 100, height: 150 }, margin: { top: 5, right: 5, bottom: 5, left: 5 } }, + }) + const geometry = computePageGeometry(doc) + expect(geometry.width).toBe(100) + expect(geometry.height).toBe(150) + expect(geometry.contentWidth).toBe(90) + }) +}) + +describe("单页布局", () => { + it("空文档产出一页", async () => { + const result = await layout(createDocument()) + expect(result.pages).toHaveLength(1) + expect(result.pageCount).toBe(1) + }) + + it("页面尺寸写入布局结果", async () => { + const result = await layout(createDocument({ page: { paper: "A5" } })) + expect(result.pages[0]?.width).toBe(148) + expect(result.pages[0]?.height).toBe(210) + }) + + it("页眉元素被渲染", async () => { + const doc = documentWith((d) => { + const header = d.sections.find((s) => s.kind === "page-header")! + header.children.push({ + id: "h1", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 8 }, + props: { text: "销售单" }, + }) + }) + const result = await layout(doc) + const ids: string[] = [] + walkLayoutNodes(result, (node) => ids.push(node.id)) + expect(ids).toContain("h1") + }) + + it("文本内容被写入布局节点", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "detail")!.children.push({ + id: "t1", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 8 }, + props: { text: "正文" }, + }) + }) + const result = await layout(doc) + let found: string | undefined + walkLayoutNodes(result, (node) => { + if (node.id === "t1" && node.content?.kind === "text") found = node.content.text + }) + expect(found).toBe("正文") + }) + + it("页脚每页重复", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "page-footer")!.children.push({ + id: "f1", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 6 }, + props: { text: "页脚" }, + }) + }) + const result = await layout(doc) + expect(result.pages).toHaveLength(1) + const footerNode = result.pages[0]?.nodes.find((n) => n.id === "f1") + expect(footerNode).toBeDefined() + }) + + it("元素坐标为绝对坐标", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "detail")!.children.push({ + id: "t1", + type: "text", + frame: { x: 10, y: 5, width: 50, height: 8 }, + props: { text: "x" }, + }) + }) + const result = await layout(doc) + let frame: { x: number; y: number } | undefined + walkLayoutNodes(result, (node) => { + if (node.id === "t1") frame = node.frame + }) + // x 保持元素自身偏移;y = 内容区起点(15) + 页眉高度(0) + 元素 y(5) + expect(frame?.x).toBe(10) + expect(frame?.y).toBeGreaterThanOrEqual(15) + }) + + it("无 TextMeasurer 时产生诊断", async () => { + const result = await layoutDocument(createDocument(), emptyContext(), { fontResolver }) + expect(result.diagnostics.some((d) => d.message.includes("估算度量器"))).toBe(true) + }) + + it("提供 TextMeasurer 时不产生该诊断", async () => { + const result = await layout(createDocument()) + expect(result.diagnostics.some((d) => d.message.includes("估算度量器"))).toBe(false) + }) +}) + +describe("多页分页", () => { + it("内容超出页面时产生多页", async () => { + const doc = documentWith((d) => { + const detail = d.sections.find((s) => s.kind === "detail")! + // 明细区放置一个很高的表格,迫使分页 + d.datasets = { ds1: { id: "ds1" } } + detail.children.push( + createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [ + { id: "c1", width: 90, header: "名称" }, + { id: "c2", width: 90, header: "数量" }, + ]), + ) + }) + const context: RenderContext = { + data: { + ds1: Array.from({ length: 100 }, (_, i) => ({ name: `项目${i}`, qty: i })), + }, + parameters: {}, + } + const result = await layout(doc, context) + expect(result.pages.length).toBeGreaterThan(1) + expect(result.pageCount).toBe(result.pages.length) + }) + + it("页码连续递增", async () => { + const doc = documentWith((d) => { + d.datasets = { ds1: { id: "ds1" } } + d.sections.find((s) => s.kind === "detail")!.children.push( + createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [ + { id: "c1", width: 180, header: "值" }, + ]), + ) + }) + const context: RenderContext = { + data: { ds1: Array.from({ length: 60 }, (_, i) => ({ value: i })) }, + parameters: {}, + } + const result = await layout(doc, context) + result.pages.forEach((page, index) => { + expect(page.pageNumber).toBe(index + 1) + }) + }) + + it("页眉在每一页都出现", async () => { + const doc = documentWith((d) => { + d.datasets = { ds1: { id: "ds1" } } + d.sections.find((s) => s.kind === "page-header")!.children.push({ + id: "hdr", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 8 }, + props: { text: "页眉" }, + }) + d.sections.find((s) => s.kind === "detail")!.children.push( + createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [ + { id: "c1", width: 180, header: "值" }, + ]), + ) + }) + const context: RenderContext = { + data: { ds1: Array.from({ length: 80 }, (_, i) => ({ value: i })) }, + parameters: {}, + } + const result = await layout(doc, context) + expect(result.pages.length).toBeGreaterThan(1) + for (const page of result.pages) { + expect(page.nodes.some((n) => n.id === "hdr")).toBe(true) + } + }) + + it("页脚在每一页都出现", async () => { + const doc = documentWith((d) => { + d.datasets = { ds1: { id: "ds1" } } + d.sections.find((s) => s.kind === "page-footer")!.children.push({ + id: "ftr", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 6 }, + props: { text: "页脚" }, + }) + d.sections.find((s) => s.kind === "detail")!.children.push( + createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [ + { id: "c1", width: 180, header: "值" }, + ]), + ) + }) + const context: RenderContext = { + data: { ds1: Array.from({ length: 80 }, (_, i) => ({ value: i })) }, + parameters: {}, + } + const result = await layout(doc, context) + for (const page of result.pages) { + expect(page.nodes.some((n) => n.id === "ftr")).toBe(true) + } + }) + + it("不存在内容溢出的页(除超高大块外)", async () => { + const doc = documentWith((d) => { + d.datasets = { ds1: { id: "ds1" } } + d.sections.find((s) => s.kind === "detail")!.children.push( + createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [ + { id: "c1", width: 180, header: "值" }, + ]), + ) + }) + const context: RenderContext = { + data: { ds1: Array.from({ length: 50 }, (_, i) => ({ value: i })) }, + parameters: {}, + } + const result = await layout(doc, context) + expect(result.diagnostics.filter((d) => d.code === "layout.page-overflow")).toEqual([]) + }) + + // 回归测试:曾出现"表头只出现在第一页"的问题—— + // 分页器记录了 isHeader 标记却没有真正在后续页面补插表头。 + it("明细表表头在每一页都重复", async () => { + const doc = documentWith((d) => { + d.datasets = { ds1: { id: "ds1" } } + d.sections.find((s) => s.kind === "detail")!.children.push( + createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [ + { id: "c1", width: 180, header: "值" }, + ]), + ) + }) + const context: RenderContext = { + data: { ds1: Array.from({ length: 200 }, (_, i) => ({ value: i })) }, + parameters: {}, + } + const result = await layout(doc, context) + expect(result.pages.length).toBeGreaterThan(1) + for (const page of result.pages) { + const headers = page.nodes.filter((n) => n.role === "table-header") + expect(headers).toHaveLength(1) + } + }) + + it("表头重复时不超出内容区", async () => { + const doc = documentWith((d) => { + d.datasets = { ds1: { id: "ds1" } } + d.sections.find((s) => s.kind === "detail")!.children.push( + createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [ + { id: "c1", width: 180, header: "值" }, + ]), + ) + }) + const context: RenderContext = { + data: { ds1: Array.from({ length: 200 }, (_, i) => ({ value: i })) }, + parameters: {}, + } + const result = await layout(doc, context) + // 内容区底部 = 15 + 267 = 282mm + const contentBottom = 282 + for (const page of result.pages) { + for (const node of page.nodes) { + expect(node.frame.y + node.frame.height).toBeLessThanOrEqual(contentBottom + 0.01) + } + } + }) + + it("repeatHeaderOnEachPage 为 false 时表头只出现一次", async () => { + const doc = documentWith((d) => { + d.datasets = { ds1: { id: "ds1" } } + const table = createTableElement( + "tbl1", + { x: 0, y: 0, width: 180, height: 20 }, + "ds1", + [{ id: "c1", width: 180, header: "值" }], + ) + table.repeatHeaderOnEachPage = false + d.sections.find((s) => s.kind === "detail")!.children.push(table) + }) + const context: RenderContext = { + data: { ds1: Array.from({ length: 200 }, (_, i) => ({ value: i })) }, + parameters: {}, + } + const result = await layout(doc, context) + expect(result.pages.length).toBeGreaterThan(1) + // 仅首页有表头 + expect(result.pages[0]!.nodes.some((n) => n.role === "table-header")).toBe(true) + for (let i = 1; i < result.pages.length; i += 1) { + expect(result.pages[i]!.nodes.some((n) => n.role === "table-header")).toBe(false) + } + }) +}) + +describe("页眉页脚过高", () => { + it("页眉页脚占满内容区时报错并返回空页集", async () => { + const doc = documentWith((d) => { + const header = d.sections.find((s) => s.kind === "page-header")! + const footer = d.sections.find((s) => s.kind === "page-footer")! + // 页眉 200mm + 页脚 200mm 远超 267mm 内容区 + header.children.push({ + id: "big-header", + type: "rect", + frame: { x: 0, y: 0, width: 180, height: 200 }, + }) + footer.children.push({ + id: "big-footer", + type: "rect", + frame: { x: 0, y: 0, width: 180, height: 200 }, + }) + }) + const result = await layout(doc) + expect(result.pages).toHaveLength(0) + expect(result.diagnostics.some((d) => d.code === "layout.page-overflow")).toBe(true) + }) +}) + +describe("Y 坐标分层", () => { + it("页眉在上、页脚在下、主体居中", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "page-header")!.children.push({ + id: "hdr", + type: "rect", + frame: { x: 0, y: 0, width: 100, height: 10 }, + }) + d.sections.find((s) => s.kind === "page-footer")!.children.push({ + id: "ftr", + type: "rect", + frame: { x: 0, y: 0, width: 100, height: 10 }, + }) + d.sections.find((s) => s.kind === "detail")!.children.push({ + id: "body", + type: "rect", + frame: { x: 0, y: 0, width: 100, height: 10 }, + }) + }) + const result = await layout(doc) + const page = result.pages[0]! + const hdr = page.nodes.find((n) => n.id === "hdr")! + const body = page.nodes.find((n) => n.id === "body")! + const ftr = page.nodes.find((n) => n.id === "ftr")! + + expect(hdr.frame.y).toBeLessThan(body.frame.y) + expect(body.frame.y).toBeLessThan(ftr.frame.y) + // 页脚贴内容区底部:15 + 267 - 10 = 272 + expect(ftr.frame.y).toBeCloseTo(272, 4) + }) +}) + +describe("区域记录", () => { + it("页面记录区域落位信息", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "page-header")!.children.push({ + id: "hdr", + type: "rect", + frame: { x: 0, y: 0, width: 100, height: 10 }, + }) + }) + const result = await layout(doc) + const kinds = result.pages[0]!.sections.map((s) => s.kind) + expect(kinds).toContain("page-header") + }) +}) + +describe("元素类型渲染", () => { + it("rect 产出 rect 内容", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "detail")!.children.push({ + id: "r1", + type: "rect", + frame: { x: 0, y: 0, width: 20, height: 10 }, + }) + }) + const result = await layout(doc) + let kind: string | undefined + walkLayoutNodes(result, (node) => { + if (node.id === "r1") kind = node.content?.kind + }) + expect(kind).toBe("rect") + }) + + it("line 产出 line 内容", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "detail")!.children.push({ + id: "l1", + type: "line", + frame: { x: 0, y: 0, width: 50, height: 0.2 }, + props: { direction: "horizontal" }, + }) + }) + const result = await layout(doc) + let content: { kind: string; direction?: string } | undefined + walkLayoutNodes(result, (node) => { + if (node.id === "l1" && node.content?.kind === "line") content = node.content + }) + expect(content?.kind).toBe("line") + expect(content?.direction).toBe("horizontal") + }) + + it("image 产出 image 内容", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "detail")!.children.push({ + id: "i1", + type: "image", + frame: { x: 0, y: 0, width: 20, height: 20 }, + props: { src: "logo.png" }, + }) + }) + const result = await layout(doc) + let src: string | undefined + walkLayoutNodes(result, (node) => { + if (node.id === "i1" && node.content?.kind === "image") src = node.content.src + }) + expect(src).toBe("logo.png") + }) + + it("container 子元素被递归布局", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "detail")!.children.push({ + id: "c1", + type: "container", + frame: { x: 0, y: 0, width: 50, height: 30 }, + children: [ + { + id: "child1", + type: "text", + frame: { x: 2, y: 2, width: 20, height: 6 }, + props: { text: "子元素" }, + }, + ], + }) + }) + const result = await layout(doc) + const ids: string[] = [] + walkLayoutNodes(result, (node) => ids.push(node.id)) + expect(ids).toContain("child1") + }) +}) + +describe("动态高度", () => { + it("长文本自动增高", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "detail")!.children.push({ + id: "long", + type: "text", + // 高度只给 5mm,但内容需要多行 + frame: { x: 0, y: 0, width: 20, height: 5 }, + props: { text: "这是一段很长的中文文本需要换行显示" }, + }) + }) + const result = await layout(doc) + let height: number | undefined + walkLayoutNodes(result, (node) => { + if (node.id === "long") height = node.frame.height + }) + expect(height).toBeGreaterThan(5) + }) + + it("短文本保持原高度", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "detail")!.children.push({ + id: "short", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 20 }, + props: { text: "短" }, + }) + }) + const result = await layout(doc) + let height: number | undefined + walkLayoutNodes(result, (node) => { + if (node.id === "short") height = node.frame.height + }) + expect(height).toBe(20) + }) +}) + +describe("未知区域处理", () => { + it("V1 之外的区域被跳过并产生提示", async () => { + const doc = createDocument() + doc.sections.push({ + id: "sum1", + kind: "summary", + children: [ + { id: "s1", type: "text", frame: { x: 0, y: 0, width: 50, height: 8 }, props: { text: "汇总" } }, + ], + }) + const result = await layout(doc) + const ids: string[] = [] + walkLayoutNodes(result, (node) => ids.push(node.id)) + expect(ids).not.toContain("s1") + expect(result.diagnostics.some((d) => d.message.includes("不在 V1 渲染范围内"))).toBe(true) + }) +}) + +describe("节点统计", () => { + it("countLayoutNodes 统计所有页节点", async () => { + const doc = documentWith((d) => { + d.sections.find((s) => s.kind === "detail")!.children.push({ + id: "a", + type: "text", + frame: { x: 0, y: 0, width: 50, height: 8 }, + props: { text: "a" }, + }) + }) + const result = await layout(doc) + expect(countLayoutNodes(result)).toBeGreaterThan(0) + }) +}) diff --git a/code/one-designer/packages/report-layout/tests/text-wrap.test.ts b/code/one-designer/packages/report-layout/tests/text-wrap.test.ts new file mode 100644 index 00000000..d2cd01bc --- /dev/null +++ b/code/one-designer/packages/report-layout/tests/text-wrap.test.ts @@ -0,0 +1,230 @@ +/** + * 文本换行测试(对应设计文档 §14 的"中文字体、长文本"测试)。 + * + * 使用确定性估算度量器,保证断言与字体环境无关。 + */ +import { describe, expect, it } from "vitest" +import { DEFAULT_RESOLVED_STYLE, type ResolvedStyle } from "@report/core" +import { createEstimateMeasurer } from "../src/measurer.js" +import { + wrapText, + segmentText, + isTextOverflow, + measureTextHeight, + isNoLineStart, + isNoLineEnd, +} from "../src/text-wrap.js" + +const measurer = createEstimateMeasurer() + +function style(overrides: Partial = {}): ResolvedStyle { + return { ...DEFAULT_RESOLVED_STYLE, fontSize: 10, ...overrides } +} + +/** 10pt 字号下 1em ≈ 3.5278mm */ +const EM = (10 / 72) * 25.4 + +describe("segmentText 分段", () => { + it("西文按词切分并保留空格", () => { + expect(segmentText("hello world")).toEqual(["hello ", "world"]) + }) + + it("中文逐字切分", () => { + expect(segmentText("中文报表")).toEqual(["中", "文", "报", "表"]) + }) + + it("中英混排正确切分", () => { + // 空格自身成段:它终结前一个词,同时供避头点逻辑判断 + expect(segmentText("销售 abc 单")).toEqual(["销", "售", " ", "abc ", "单"]) + }) + + it("空串返回空数组", () => { + expect(segmentText("")).toEqual([]) + }) +}) + +describe("wrapText 基本换行", () => { + it("短文本不换行", () => { + const result = wrapText("abc", { maxWidth: 100, style: style(), measurer }) + expect(result.lines).toEqual(["abc"]) + }) + + it("超宽英文在词边界换行", () => { + // "hello world" 约 5em;限制到 4em 才能触发词边界换行 + const result = wrapText("hello world", { + maxWidth: 4 * EM, + style: style(), + measurer, + }) + expect(result.lines.length).toBeGreaterThan(1) + // 不拆开单词:每行去掉空格后仍应是完整单词 + for (const line of result.lines) { + expect(["hello", "world"]).toContain(line.trim()) + } + }) + + it("中文在任意字之间换行", () => { + const result = wrapText("中文报表测试", { maxWidth: 3 * EM, style: style(), measurer }) + expect(result.lines.length).toBe(2) + expect(result.lines[0]).toBe("中文报") + expect(result.lines[1]).toBe("表测试") + }) + + it("保留硬换行", () => { + const result = wrapText("a\nb", { maxWidth: 100, style: style(), measurer }) + expect(result.lines).toEqual(["a", "b"]) + }) + + it("空文本占一行", () => { + const result = wrapText("", { maxWidth: 100, style: style(), measurer }) + expect(result.lines).toEqual([""]) + expect(result.totalHeight).toBeCloseTo(EM * 1.2, 4) + }) + + it("每行宽度不超过限制(中文)", () => { + const result = wrapText("中文报表测试内容", { maxWidth: 2 * EM, style: style(), measurer }) + for (const width of result.widths) { + expect(width).toBeLessThanOrEqual(2 * EM + 1e-6) + } + }) + + it("长文本换行后行数合理", () => { + const text = "中".repeat(100) + const result = wrapText(text, { maxWidth: 10 * EM, style: style(), measurer }) + expect(result.lines.length).toBe(10) + }) + + it("总高度 = 行数 × 行高", () => { + const result = wrapText("中".repeat(10), { maxWidth: 5 * EM, style: style(), measurer }) + expect(result.totalHeight).toBeCloseTo(result.lineHeight * result.lines.length, 4) + }) + + it("行高随字号变化", () => { + const a = wrapText("x", { maxWidth: 100, style: style({ fontSize: 10 }), measurer }) + const b = wrapText("x", { maxWidth: 100, style: style({ fontSize: 20 }), measurer }) + expect(b.lineHeight).toBeCloseTo(a.lineHeight * 2, 4) + }) +}) + +describe("wrapText 强制断词", () => { + it("超长单词被强制断开", () => { + const result = wrapText("abcdefghijklmnop", { maxWidth: 5 * EM, style: style(), measurer }) + expect(result.brokeWords).toBe(true) + expect(result.lines.length).toBeGreaterThan(1) + }) + + it("禁用断词时整词放一行(允许溢出)", () => { + const result = wrapText("abcdefghijklmnop", { + maxWidth: 5 * EM, + style: style(), + measurer, + breakLongWords: false, + }) + expect(result.brokeWords).toBe(false) + expect(result.lines).toEqual(["abcdefghijklmnop"]) + }) + + it("超长中文串不触发 brokeWords(逐字即可换行)", () => { + const result = wrapText("中".repeat(20), { maxWidth: 3 * EM, style: style(), measurer }) + expect(result.brokeWords).toBe(false) + }) +}) + +describe("避头点规则", () => { + it("识别不可行首的标点", () => { + expect(isNoLineStart(",")).toBe(true) + expect(isNoLineStart("。")).toBe(true) + expect(isNoLineStart(")")).toBe(true) + expect(isNoLineStart("中")).toBe(false) + }) + + it("识别不可行尾的标点", () => { + expect(isNoLineEnd("(")).toBe(true) + expect(isNoLineEnd("中")).toBe(false) + }) + + it("空串两种规则都返回 false", () => { + expect(isNoLineStart("")).toBe(false) + expect(isNoLineEnd("")).toBe(false) + }) + + it("行尾不出现左括号", () => { + // 宽度刚好让 "(" 落在行尾时,应把它推到下一行 + const result = wrapText("中文(测试)", { maxWidth: 3 * EM, style: style(), measurer }) + for (const line of result.lines) { + if (line === "") continue + expect(isNoLineEnd(line)).toBe(false) + } + }) + + it("行首不出现逗号", () => { + const result = wrapText("中文,测试", { maxWidth: 3 * EM, style: style(), measurer }) + for (const line of result.lines) { + if (line === "") continue + expect(isNoLineStart(line)).toBe(false) + } + }) +}) + +describe("measureTextHeight", () => { + it("返回高度与行数", () => { + const result = measureTextHeight("中".repeat(10), { + maxWidth: 5 * EM, + style: style(), + measurer, + }) + expect(result.lineCount).toBe(2) + expect(result.height).toBeGreaterThan(0) + }) + + it("指定最大高度时能识别溢出", () => { + const result = measureTextHeight( + "中".repeat(10), + { maxWidth: 5 * EM, style: style(), measurer }, + EM, // 只允许一行 + ) + expect(result.overflow).toBe(true) + }) + + it("未指定最大高度时 overflow 恒为 false", () => { + const result = measureTextHeight("中".repeat(100), { + maxWidth: 5 * EM, + style: style(), + measurer, + }) + expect(result.overflow).toBe(false) + }) +}) + +describe("isTextOverflow", () => { + it("内容超出高度返回 true", () => { + expect(isTextOverflow("中".repeat(10), 5 * EM, EM, style(), measurer)).toBe(true) + }) + + it("内容未超出返回 false", () => { + expect(isTextOverflow("中", 5 * EM, 10 * EM, style(), measurer)).toBe(false) + }) +}) + +describe("边界情况", () => { + it("零宽度时每字一行", () => { + const result = wrapText("中文字", { maxWidth: 0, style: style(), measurer }) + expect(result.lines.length).toBe(3) + }) + + it("纯空白文本", () => { + const result = wrapText(" ", { maxWidth: 100, style: style(), measurer }) + expect(result.lines.length).toBeGreaterThanOrEqual(1) + }) + + it("连续硬换行保留空行", () => { + const result = wrapText("a\n\nb", { maxWidth: 100, style: style(), measurer }) + expect(result.lines).toEqual(["a", "", "b"]) + }) + + it("标签或特殊字符不崩溃", () => { + expect(() => + wrapText("粗体 & \"引号\"", { maxWidth: 3 * EM, style: style(), measurer }), + ).not.toThrow() + }) +}) diff --git a/code/one-designer/packages/report-layout/tsconfig.json b/code/one-designer/packages/report-layout/tsconfig.json new file mode 100644 index 00000000..23cc1372 --- /dev/null +++ b/code/one-designer/packages/report-layout/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/code/one-designer/packages/report-layout/tsconfig.test.json b/code/one-designer/packages/report-layout/tsconfig.test.json new file mode 100644 index 00000000..6cefc694 --- /dev/null +++ b/code/one-designer/packages/report-layout/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "*.config.ts"] +} diff --git a/code/one-designer/packages/report-layout/tsup.config.ts b/code/one-designer/packages/report-layout/tsup.config.ts new file mode 100644 index 00000000..bd9d9bd4 --- /dev/null +++ b/code/one-designer/packages/report-layout/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + // fonts 单独入口:Node 侧字体解析会用到 node:fs, + // 与纯逻辑入口分离,避免浏览器打包被迫引入 Node 内置模块。 + entry: ["src/index.ts", "src/fonts.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + target: "es2022", + external: ["node:fs", "node:path"], +}) diff --git a/code/one-designer/packages/report-layout/vitest.config.ts b/code/one-designer/packages/report-layout/vitest.config.ts new file mode 100644 index 00000000..0af27743 --- /dev/null +++ b/code/one-designer/packages/report-layout/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + }, +}) diff --git a/code/one-designer/packages/report-renderer/package.json b/code/one-designer/packages/report-renderer/package.json new file mode 100644 index 00000000..16eb8438 --- /dev/null +++ b/code/one-designer/packages/report-renderer/package.json @@ -0,0 +1,33 @@ +{ + "name": "@report/renderer", + "version": "0.1.0", + "description": "消费 LayoutDocument 的 HTML / SVG / PDF 渲染器与浏览器打印", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit -p tsconfig.test.json" + }, + "dependencies": { + "@report/core": "workspace:*", + "@report/layout": "workspace:*" + }, + "devDependencies": { + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/code/one-designer/packages/report-renderer/src/html.ts b/code/one-designer/packages/report-renderer/src/html.ts new file mode 100644 index 00000000..ef547a7a --- /dev/null +++ b/code/one-designer/packages/report-renderer/src/html.ts @@ -0,0 +1,250 @@ +/** + * HTML Renderer(对应设计文档 §4.4、§3.3)。 + * + * 消费 LayoutDocument,产出可直接打印的 HTML 文档字符串。 + * 每页一个 ,绝对定位的节点用内联样式表达, + * 便于浏览器打印时按页分页。 + */ +import type { Diagnostic } from "@report/core" +import type { + LayoutDocument, + LayoutNode, + LayoutPage, + TableContent, +} from "@report/layout" +import type { RenderOptions, TextRenderResult } from "./types.js" +import { styleToCss, toUnit, unitSuffix } from "./style.js" + +/** HTML 渲染器。 */ +export interface HtmlRenderer { + render(document: LayoutDocument, options?: RenderOptions): TextRenderResult +} + +/** 创建 HTML 渲染器。 */ +export function createHtmlRenderer(): HtmlRenderer { + return { + render(document, options = {}) { + return renderHtml(document, options) + }, + } +} + +/** + * 把布局文档渲染为 HTML。 + * + * 产出一个完整的 HTML 文档(含 打印样式),页面用 + * `page-break-after` 实现浏览器打印分页。 + */ +export function renderHtml(document: LayoutDocument, options: RenderOptions = {}): TextRenderResult { + const diagnostics: Diagnostic[] = [] + const unit = unitSuffix(options) + const pages = document.pages + .map((page) => renderHtmlPage(page, options, diagnostics)) + .join("\n") + + const content = + `\n\n\n\n` + + `\n\n\n` + + `${pages}\n\n` + + return { + target: "html", + pageCount: document.pageCount, + diagnostics, + content, + } +} + +/** 打印样式表。 */ +function printCss(unit: string): string { + return [ + "* { box-sizing: border-box; margin: 0; padding: 0; }", + "body { background: #f5f5f5; }", + ".rd-page {", + " position: relative;", + " background: white;", + " margin: 0 auto;", + " box-shadow: 0 1px 3px rgba(0,0,0,0.2);", + " page-break-after: always;", + "}", + ".rd-node { position: absolute; overflow: hidden; }", + ".rd-text { display: flex; }", + ".rd-table { position: absolute; }", + ".rd-table table { border-collapse: collapse; width: 100%; }", + ".rd-table th, .rd-table td {", + " padding: 0;", + " overflow: hidden;", + " white-space: nowrap;", + "}", + "@media print {", + " body { background: white; }", + " .rd-page { box-shadow: none; margin: 0; }", + "}", + "", + ].join("\n") +} + +/** 渲染单页为 。 */ +function renderHtmlPage( + page: LayoutPage, + options: RenderOptions, + diagnostics: Diagnostic[], +): string { + const width = toUnit(page.width, options) + const height = toUnit(page.height, options) + const unit = unitSuffix(options) + const nodes = page.nodes + .map((node) => renderHtmlNode(node, options, diagnostics)) + .join("\n") + + return ( + `\n` + + `${nodes}\n` + + `` + ) +} + +/** 渲染单个布局节点为 HTML 元素。 */ +function renderHtmlNode( + node: LayoutNode, + options: RenderOptions, + diagnostics: Diagnostic[], +): string { + const x = toUnit(node.frame.x, options) + const y = toUnit(node.frame.y, options) + const width = toUnit(node.frame.width, options) + const height = toUnit(node.frame.height, options) + const unit = unitSuffix(options) + const pos = `position:absolute;left:${x}${unit};top:${y}${unit};width:${width}${unit};height:${height}${unit}` + const id = escapeAttr(node.id) + + switch (node.type) { + case "text": { + const content = node.content + if (content?.kind !== "text") return "" + const css = styleToCss(node.style) + const text = escapeHtml(content.text).replace(/\n/g, "") + return ( + `` + + `${text}` + ) + } + case "rect": { + const css = styleToCss(node.style) + return `` + } + case "line": { + const content = node.content + if (content?.kind !== "line") return "" + const color = node.style.color ?? "#000000" + const thickness = toUnit(content.thickness, options) + const isVertical = content.direction === "vertical" + const lineCss = + isVertical + ? `border-left:${thickness}${unit} solid ${color}` + : `border-top:${thickness}${unit} solid ${color}` + return `` + } + case "image": { + const content = node.content + if (content?.kind !== "image") return "" + if (!content.src) { + diagnostics.push({ + severity: "warning", + code: "render.image-missing", + message: `图片元素 "${node.id}" 缺少 src`, + elementId: node.id, + }) + return "" + } + const fit = content.fit === "fill" ? "100% 100%" : content.fit + return ( + `` + + `` + + `` + ) + } + case "table": { + const content = node.content + if (content?.kind !== "table") return "" + return renderHtmlTable(node, content, options) + } + default: { + diagnostics.push({ + severity: "warning", + code: "render.unknown-node", + message: `不支持的节点类型 "${node.type}",已跳过`, + elementId: node.id, + }) + return "" + } + } +} + +/** 渲染表格节点为 HTML 表格。 */ +function renderHtmlTable( + node: LayoutNode, + content: TableContent, + options: RenderOptions, +): string { + const x = toUnit(node.frame.x, options) + const y = toUnit(node.frame.y, options) + const width = toUnit(node.frame.width, options) + const height = toUnit(node.frame.height, options) + const unit = unitSuffix(options) + const id = escapeAttr(node.id) + + const colgroup = content.columns + .map((col) => ``) + .join("") + + const headerRow = content.showHeader + ? `${content.columns + .map((col) => { + const align = `text-align:${col.align}` + return `${escapeHtml(col.header ?? "")}` + }) + .join("")}` + : "" + + const bodyRows = content.rows + .map((row) => { + const cells = content.columns + .map((col) => { + const cell = row.cells.find((c) => c.columnId === col.id) + const align = `text-align:${col.align}` + return `${escapeHtml(cell?.text ?? "")}` + }) + .join("") + return `${cells}` + }) + .join("") + + const borderCss = content.showGrid ? "border-collapse:collapse;" : "" + const cellBorder = content.showGrid ? "border:1px solid #000;" : "" + + return ( + `` + + `` + + `${colgroup}` + + (content.showHeader ? `${headerRow}` : "") + + `${bodyRows}` + + `` + ) +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") +} + +function escapeAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) +} diff --git a/code/one-designer/packages/report-renderer/src/index.ts b/code/one-designer/packages/report-renderer/src/index.ts new file mode 100644 index 00000000..d411cef5 --- /dev/null +++ b/code/one-designer/packages/report-renderer/src/index.ts @@ -0,0 +1,29 @@ +/** + * @report/renderer 公共入口(对应设计文档 §4.4、§3.3)。 + * + * Renderer 只消费 LayoutDocument,把分页结果绘制成 HTML / SVG / PDF。 + * 所有 Renderer 共享同一份布局结果,保证分页结构一致(§15)。 + */ +export type { + RenderTarget, + RenderResult, + TextRenderResult, + BinaryRenderResult, + RenderOptions, + Renderer, +} from "./types.js" + +// 样式与坐标共享工具 +export { styleToCss, styleToSvgTextAttrs, toUnit, unitSuffix } from "./style.js" + +// SVG Renderer +export type { SvgRenderer } from "./svg.js" +export { createSvgRenderer, renderSvg } from "./svg.js" + +// HTML Renderer +export type { HtmlRenderer } from "./html.js" +export { createHtmlRenderer, renderHtml } from "./html.js" + +// PDF Renderer 契约 +export type { PdfBackend, PdfRenderer } from "./pdf.js" +export { createPdfRenderer } from "./pdf.js" diff --git a/code/one-designer/packages/report-renderer/src/pdf.ts b/code/one-designer/packages/report-renderer/src/pdf.ts new file mode 100644 index 00000000..577f74e6 --- /dev/null +++ b/code/one-designer/packages/report-renderer/src/pdf.ts @@ -0,0 +1,78 @@ +/** + * PDF Renderer 契约(对应设计文档 §4.4、§3.3)。 + * + * PDF 的具体后端可运行在浏览器或 Node/服务端,但都遵循统一的 + * Renderer 接口。本包只提供契约与一个"占位"实现,真实 PDF 生成 + * 由阶段 5 结合部署环境确定后端(§16)。 + * + * 设计要点: + * - PDF 是二进制输出,结果携带 Uint8Array。 + * - 后端缺失或未配置时返回诊断而非抛异常(§13)。 + */ +import type { Diagnostic } from "@report/core" +import type { LayoutDocument } from "@report/layout" +import type { BinaryRenderResult, RenderOptions } from "./types.js" + +/** + * PDF 后端契约。 + * + * 宿主实现此接口,把 LayoutDocument 转成 PDF 字节。 + * 典型后端:浏览器端 pdf-lib / jsPDF,Node 端 Puppeteer / pdfkit。 + */ +export interface PdfBackend { + /** 后端名称,用于诊断与日志。 */ + readonly name: string + /** 把布局文档渲染为 PDF 字节。 */ + render(document: LayoutDocument, options?: RenderOptions): Uint8Array +} + +/** PDF 渲染器。 */ +export interface PdfRenderer { + render(document: LayoutDocument, options?: RenderOptions): BinaryRenderResult +} + +/** 创建 PDF 渲染器。 */ +export function createPdfRenderer(backend?: PdfBackend): PdfRenderer { + return { + render(document, options = {}) { + const diagnostics: Diagnostic[] = [] + if (!backend) { + diagnostics.push({ + severity: "warning", + code: "render.pdf-backend-missing", + message: + "未配置 PDF 后端;请注入 PdfBackend(如 pdf-lib / jsPDF / Puppeteer)。" + + "当前返回空字节。", + }) + return { + target: "pdf", + pageCount: document.pageCount, + diagnostics, + bytes: new Uint8Array(0), + } + } + + try { + const bytes = backend.render(document, options) + return { + target: "pdf", + pageCount: document.pageCount, + diagnostics, + bytes, + } + } catch (error) { + diagnostics.push({ + severity: "error", + code: "render.pdf-backend-error", + message: `PDF 后端 "${backend.name}" 渲染失败:${(error as Error)?.message ?? String(error)}`, + }) + return { + target: "pdf", + pageCount: document.pageCount, + diagnostics, + bytes: new Uint8Array(0), + } + } + }, + } +} diff --git a/code/one-designer/packages/report-renderer/src/style.ts b/code/one-designer/packages/report-renderer/src/style.ts new file mode 100644 index 00000000..164544f0 --- /dev/null +++ b/code/one-designer/packages/report-renderer/src/style.ts @@ -0,0 +1,61 @@ +/** + * 样式与坐标转换的共享工具(HTML / SVG 共用)。 + * + * 布局结果内部统一使用 mm;SVG/HTML 输出按需换算为 px。 + * 样式统一转成 CSS 声明,保证各 Renderer 视觉一致(§15)。 + */ +import type { ResolvedStyle } from "@report/core" +import { mmToPx } from "@report/layout" +import type { RenderOptions } from "./types.js" + +/** 把 mm 值按目标单位换算为数值(SVG/HTML 里直接写数字)。 */ +export function toUnit(value: number, options: RenderOptions): number { + return options.unit === "px" ? mmToPx(value) : value +} + +/** 单位后缀。 */ +export function unitSuffix(options: RenderOptions): string { + return options.unit === "px" ? "px" : "mm" +} + +/** + * 把已解析样式转成 CSS 声明字符串。 + * + * 字号在文档中是 pt,HTML/SVG 里也以 pt 表达,保证与布局测量一致。 + * 边框只输出四边统一值;逐边覆盖的边框由 Renderer 按需处理。 + */ +export function styleToCss(style: ResolvedStyle): string { + const parts: string[] = [] + parts.push(`font-family:${style.fontFamily}`) + parts.push(`font-size:${style.fontSize}pt`) + parts.push(`font-weight:${style.fontWeight}`) + parts.push(`font-style:${style.fontStyle}`) + parts.push(`line-height:${style.lineHeight}`) + parts.push(`color:${style.color}`) + if (style.background && style.background !== "transparent") { + parts.push(`background:${style.background}`) + } + parts.push(`text-align:${style.align}`) + parts.push(`text-decoration:${style.textDecoration}`) + if (style.border) { + const width = style.border.width ?? 0.1 + const color = style.border.color ?? "#000000" + const lineStyle = style.border.style ?? "solid" + parts.push(`border:${width}mm ${lineStyle} ${color}`) + } + return parts.join(";") +} + +/** 把已解析样式转成 SVG 文本属性(fill / font-size 等)。 */ +export function styleToSvgTextAttrs(style: ResolvedStyle): Record { + const attrs: Record = { + fill: style.color, + "font-family": style.fontFamily, + "font-size": `${style.fontSize}pt`, + "font-weight": String(style.fontWeight), + "font-style": style.fontStyle, + } + if (style.textDecoration === "underline") attrs["text-decoration"] = "underline" + if (style.textDecoration === "line-through") attrs["text-decoration"] = "line-through" + return attrs +} diff --git a/code/one-designer/packages/report-renderer/src/svg.ts b/code/one-designer/packages/report-renderer/src/svg.ts new file mode 100644 index 00000000..45676db2 --- /dev/null +++ b/code/one-designer/packages/report-renderer/src/svg.ts @@ -0,0 +1,293 @@ +/** + * SVG Renderer(对应设计文档 §4.4、§3.3)。 + * + * 消费 LayoutDocument,产出可直接打印的 SVG 文档字符串。 + * SVG 的 viewBox 以 mm 为单位标注,便于与打印尺寸精确对应。 + */ +import type { Diagnostic } from "@report/core" +import type { + LayoutDocument, + LayoutNode, + LayoutPage, + TableContent, +} from "@report/layout" +import type { RenderOptions, TextRenderResult } from "./types.js" +import { styleToSvgTextAttrs } from "./style.js" + +/** SVG 渲染器。 */ +export interface SvgRenderer { + render(document: LayoutDocument, options?: RenderOptions): TextRenderResult +} + +/** 创建 SVG 渲染器。 */ +export function createSvgRenderer(): SvgRenderer { + return { + render(document, options = {}) { + return renderSvg(document, options) + }, + } +} + +/** + * 把布局文档渲染为 SVG。 + * + * 每个页面产出一个 ,包含在单个 下, + * 页面之间用空行分隔,便于宿主逐页切分或整体打印。 + */ +export function renderSvg(document: LayoutDocument, options: RenderOptions = {}): TextRenderResult { + const diagnostics: Diagnostic[] = [] + const pages = document.pages.map((page) => renderSvgPage(page, options, diagnostics)) + + const content = pages.join("\n\n") + return { + target: "svg", + pageCount: document.pageCount, + diagnostics, + content, + } +} + +/** 渲染单页为 元素。 */ +function renderSvgPage( + page: LayoutPage, + options: RenderOptions, + diagnostics: Diagnostic[], +): string { + const width = page.width + const height = page.height + const nodes = page.nodes.map((node) => renderSvgNode(node, options, diagnostics)).join("\n") + + return ( + `\n` + + `\n` + + `${nodes}\n` + + `` + ) +} + +/** 渲染单个布局节点为 SVG 元素。 */ +function renderSvgNode( + node: LayoutNode, + options: RenderOptions, + diagnostics: Diagnostic[], +): string { + const { x, y, width, height } = node.frame + const id = escapeAttr(node.id) + + switch (node.type) { + case "text": { + const content = node.content + if (content?.kind !== "text") return "" + const attrs = styleToSvgTextAttrs(node.style) + const lines = content.lines + // 逐行输出 ,行高按 lineHeight(mm)累加 + const lineHeightMm = content.lineHeight + const anchor = node.style.align === "center" ? "middle" : node.style.align === "right" ? "end" : "start" + const textX = anchor === "start" ? x : anchor === "middle" ? x + width / 2 : x + width + const rows = lines + .map((line, i) => { + const ly = y + lineHeightMm * (i + 1) + return ( + `${escapeText(line)}` + ) + }) + .join("\n") + return rows + } + case "rect": { + const fill = node.style.background && node.style.background !== "transparent" + ? node.style.background + : "none" + const stroke = node.style.border ? (node.style.border.color ?? "#000000") : "none" + const strokeWidth = node.style.border ? (node.style.border.width ?? 0.1) : 0 + return ( + `` + ) + } + case "line": { + const content = node.content + if (content?.kind !== "line") return "" + const color = node.style.color ?? "#000000" + const thickness = content.thickness + const direction = content.direction + if (direction === "vertical") { + const cx = x + width / 2 + return ( + `` + ) + } + const cy = y + height / 2 + return ( + `` + ) + } + case "image": { + const content = node.content + if (content?.kind !== "image") return "" + // SVG 图片:若无 src 则记录诊断并跳过 + if (!content.src) { + diagnostics.push({ + severity: "warning", + code: "render.image-missing", + message: `图片元素 "${node.id}" 缺少 src`, + elementId: node.id, + }) + return "" + } + const preserve = fitToPreserve(content.fit) + return ( + `` + ) + } + case "table": { + const content = node.content + if (content?.kind !== "table") return "" + return renderSvgTable(node, content, options, diagnostics) + } + default: { + // 未知节点类型:不抛异常,记录诊断并跳过(§13) + diagnostics.push({ + severity: "warning", + code: "render.unknown-node", + message: `不支持的节点类型 "${node.type}",已跳过`, + elementId: node.id, + }) + return "" + } + } +} + +/** 渲染表格节点(含表头、行、单元格网格)。 */ +function renderSvgTable( + node: LayoutNode, + content: TableContent, + options: RenderOptions, + diagnostics: Diagnostic[], +): string { + const { x, y, width } = node.frame + const parts: string[] = [] + const headerHeight = content.headerHeight + const rowHeight = content.rowHeight + + // 表格外框 + if (content.showGrid) { + parts.push( + ``, + ) + } + + // 列分隔线 + if (content.showGrid) { + let cx = x + for (const col of content.columns) { + cx += col.width + parts.push( + ``, + ) + } + } + + // 表头 + if (content.showHeader) { + const rowY = y + for (const col of content.columns) { + if (col.header === undefined) continue + const colX = x + col.x + const attrs = styleToSvgTextAttrs({ ...defaultCellStyle(), align: col.align }) + const anchor = col.align === "center" ? "middle" : col.align === "right" ? "end" : "start" + const textX = anchor === "start" ? colX : anchor === "middle" ? colX + col.width / 2 : colX + col.width + parts.push( + `${escapeText(col.header)}`, + ) + } + if (content.showGrid) { + parts.push( + ``, + ) + } + } + + // 数据行 + let rowCursor = y + (content.showHeader ? headerHeight : 0) + for (const row of content.rows) { + const rowH = row.height + for (const cell of row.cells) { + const col = content.columns.find((c) => c.id === cell.columnId) + if (!col) continue + const colX = x + col.x + const align = col.align + const anchor = align === "center" ? "middle" : align === "right" ? "end" : "start" + const textX = anchor === "start" ? colX : anchor === "middle" ? colX + col.width / 2 : colX + col.width + const attrs = styleToSvgTextAttrs({ ...defaultCellStyle(), align }) + parts.push( + `${escapeText(cell.text)}`, + ) + } + if (content.showGrid) { + parts.push( + ``, + ) + } + rowCursor += rowH + } + + return parts.join("\n") +} + +function defaultCellStyle(): import("@report/core").ResolvedStyle { + return { + fontFamily: "sans-serif", + fontSize: 10, + fontWeight: "normal", + fontStyle: "normal", + lineHeight: 1.2, + color: "#000000", + background: "transparent", + padding: { top: 0, right: 0, bottom: 0, left: 0 }, + align: "left", + verticalAlign: "top", + textDecoration: "none", + } +} + +function fitToPreserve(fit: "contain" | "cover" | "fill" | "none"): string { + switch (fit) { + case "contain": return "xMidYMid meet" + case "cover": return "xMidYMid slice" + case "none": return "none" + default: return "none" + } +} + +function attrsToStr(attrs: Record): string { + return Object.entries(attrs) + .map(([k, v]) => `${k}="${escapeAttr(v)}"`) + .join(" ") +} + +function escapeText(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") +} + +function escapeAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) +} diff --git a/code/one-designer/packages/report-renderer/src/types.ts b/code/one-designer/packages/report-renderer/src/types.ts new file mode 100644 index 00000000..8d68687b --- /dev/null +++ b/code/one-designer/packages/report-renderer/src/types.ts @@ -0,0 +1,64 @@ +/** + * Renderer 统一契约(对应设计文档 §4.4、§3.3)。 + * + * Renderer 只消费 LayoutDocument,绝不重新计算分页。 + * 所有 Renderer 针对同一份 LayoutDocument 产出结构一致的输出, + * 以保证 HTML / SVG / PDF 的分页结果一致(§15)。 + */ +import type { LayoutDocument } from "@report/layout" + +/** 渲染目标格式。 */ +export type RenderTarget = "html" | "svg" | "pdf" + +/** + * 渲染器输出。 + * + * 各 Renderer 的具体产物不同(HTML 字符串、SVG 字符串、PDF 字节), + * 但都遵循这一统一契约,便于宿主在多个目标间切换。 + */ +export interface RenderResult { + /** 目标格式。 */ + target: RenderTarget + /** 分页结果快照(供宿主校验分页一致性)。 */ + pageCount: number + /** 渲染过程中的诊断(缺失资源、不支持的节点类型等)。 */ + diagnostics: import("@report/core").Diagnostic[] +} + +/** HTML / SVG 这类文本型输出的结果。 */ +export interface TextRenderResult extends RenderResult { + /** 产出的文本(HTML 或 SVG 文档字符串)。 */ + content: string +} + +/** 二进制型输出(PDF)的结果。 */ +export interface BinaryRenderResult extends RenderResult { + /** 产出的字节(PDF 等)。 */ + bytes: Uint8Array +} + +/** 渲染选项。 */ +export interface RenderOptions { + /** + * 目标单位。默认 "mm"。 + * + * 布局结果内部统一为 mm;SVG 输出通常按 mm 标注 viewBox 便于打印, + * HTML 输出可按 mm 或 px。px 换算走 1in = 96px。 + */ + unit?: "mm" | "px" + /** 是否内联样式(默认 true,便于打印与独立保存)。 */ + inlineStyle?: boolean +} + +/** + * Renderer 契约。 + * + * 每个目标格式实现一个 Renderer。PDF 的具体后端可在浏览器或 + * Node/服务端实现,但都必须实现本契约(§4.4)。 + */ +export interface Renderer { + /** 目标格式。 */ + readonly target: RenderTarget + /** 把布局文档渲染为目标格式。 */ + render(document: LayoutDocument, options?: RenderOptions): TResult +} diff --git a/code/one-designer/packages/report-renderer/tests/renderer.test.ts b/code/one-designer/packages/report-renderer/tests/renderer.test.ts new file mode 100644 index 00000000..7af64d97 --- /dev/null +++ b/code/one-designer/packages/report-renderer/tests/renderer.test.ts @@ -0,0 +1,220 @@ +/** + * Renderer 一致性测试(对应设计文档 §15 的 + * "HTML、SVG、PDF Renderer 的公共布局结果一致性测试")。 + * + * 核心断言:同一份 LayoutDocument 经 HTML / SVG 渲染后, + * 分页结构一致(页数相同、节点都能找到)。 + */ +import { describe, expect, it } from "vitest" +import { createDocument, createTableElement, type ReportDocument } from "@report/core" +import { createEstimateMeasurer, createPassthroughFontResolver } from "@report/layout" +import { layoutDocument } from "@report/layout" +import { renderHtml, renderSvg, createPdfRenderer } from "../src/index.js" +import type { RenderContext } from "@report/layout" + +const measurer = createEstimateMeasurer() +const fontResolver = createPassthroughFontResolver() + +async function layout(doc: ReportDocument, context: RenderContext) { + return layoutDocument(doc, context, { textMeasurer: measurer, fontResolver }) +} + +/** 构造一份含页眉、文本、明细表、页脚的报表文档。 */ +function sampleDoc(): ReportDocument { + const doc = createDocument({ name: "渲染测试" }) + doc.datasets = { sales: { id: "sales" } } + + const header = doc.sections.find((s) => s.kind === "page-header")! + header.children.push({ + id: "h1", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 8 }, + props: { text: "销售单" }, + }) + + const detail = doc.sections.find((s) => s.kind === "detail")! + detail.children.push( + createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "sales", [ + { id: "c1", width: 90, header: "商品", binding: { field: "name" } }, + { id: "c2", width: 90, header: "金额", binding: { field: "amount" }, align: "right" }, + ]), + ) + + const footer = doc.sections.find((s) => s.kind === "page-footer")! + footer.children.push({ + id: "f1", + type: "text", + frame: { x: 0, y: 0, width: 100, height: 6 }, + binding: { expression: "'第 ' + page.pageNumber + ' 页'" }, + }) + + return doc +} + +const ROWS = [ + { name: "苹果", amount: 100 }, + { name: "香蕉", amount: 200 }, + { name: "橙子", amount: 300 }, +] + +describe("HTML / SVG 分页一致性", () => { + it("同一文档渲染为 HTML 与 SVG 时页数一致", async () => { + const doc = sampleDoc() + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const html = renderHtml(result) + const svg = renderSvg(result) + + expect(html.target).toBe("html") + expect(svg.target).toBe("svg") + expect(html.pageCount).toBe(result.pageCount) + expect(svg.pageCount).toBe(result.pageCount) + expect(html.pageCount).toBe(svg.pageCount) + }) + + it("HTML 输出包含与页数一致的 .rd-page 容器", async () => { + const doc = sampleDoc() + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const html = renderHtml(result) + const pageCount = (html.content.match(/class="rd-page"/g) ?? []).length + expect(pageCount).toBe(result.pageCount) + }) + + it("SVG 输出包含与页数一致的 元素", async () => { + const doc = sampleDoc() + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const svg = renderSvg(result) + const svgCount = (svg.content.match(/ { + const doc = sampleDoc() + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const html = renderHtml(result) + const svg = renderSvg(result) + + // 页眉文本 "销售单" 应出现在两种输出中 + expect(html.content).toContain("销售单") + expect(svg.content).toContain("销售单") + }) + + it("明细表数据在 HTML 与 SVG 中都被渲染", async () => { + const doc = sampleDoc() + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const html = renderHtml(result) + const svg = renderSvg(result) + + expect(html.content).toContain("苹果") + expect(html.content).toContain("橙子") + expect(svg.content).toContain("苹果") + expect(svg.content).toContain("橙子") + }) + + it("页脚页码占位符在渲染前已被替换", async () => { + const doc = sampleDoc() + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const html = renderHtml(result) + // 页脚应有 "第 1 页" 而非原始占位符 + expect(html.content).toContain("第 1 页") + expect(html.content).not.toContain("{pageNumber}") + }) +}) + +describe("多页渲染", () => { + it("多页文档在 HTML 与 SVG 中页数一致", async () => { + const doc = sampleDoc() + const manyRows = Array.from({ length: 200 }, (_, i) => ({ + name: `商品${i}`, + amount: i, + })) + const result = await layout(doc, { data: { sales: manyRows }, parameters: {} }) + + expect(result.pageCount).toBeGreaterThan(1) + + const html = renderHtml(result) + const svg = renderSvg(result) + + const htmlPages = (html.content.match(/class="rd-page"/g) ?? []).length + const svgPages = (svg.content.match(/ { + it("SVG 使用 mm 标注宽度和 viewBox", async () => { + const doc = sampleDoc() + const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) + + const svg = renderSvg(result) + const page = result.pages[0]! + // width 与 height 用 mm,viewBox 用无单位数值 + expect(svg.content).toContain(`width="${page.width}mm"`) + expect(svg.content).toContain(`viewBox="0 0 ${page.width} ${page.height}"`) + }) + + it("矩形节点渲染为 ", async () => { + const doc = createDocument({ name: "矩形测试" }) + const detail = doc.sections.find((s) => s.kind === "detail")! + detail.children.push({ + id: "r1", + type: "rect", + frame: { x: 5, y: 5, width: 50, height: 20 }, + }) + const result = await layout(doc, { data: {}, parameters: {} }) + + const svg = renderSvg(result) + expect(svg.content).toContain(" { + it("未配置后端时返回诊断而非抛异常", () => { + const result = createPdfRenderer().render({ + pages: [], + diagnostics: [], + pageCount: 0, + }) + expect(result.target).toBe("pdf") + expect(result.bytes).toBeInstanceOf(Uint8Array) + expect(result.bytes.length).toBe(0) + expect(result.diagnostics.some((d) => d.code === "render.pdf-backend-missing")).toBe(true) + }) + + it("后端抛错时返回错误诊断", () => { + const backend = { + name: "mock", + render: () => { + throw new Error("boom") + }, + } + const result = createPdfRenderer(backend).render({ + pages: [], + diagnostics: [], + pageCount: 0, + }) + expect(result.diagnostics.some((d) => d.code === "render.pdf-backend-error")).toBe(true) + }) + + it("后端正常时返回字节", () => { + const backend = { + name: "mock", + render: () => new Uint8Array([1, 2, 3]), + } + const result = createPdfRenderer(backend).render({ + pages: [], + diagnostics: [], + pageCount: 0, + }) + expect(result.bytes.length).toBe(3) + expect(result.diagnostics).toHaveLength(0) + }) +}) diff --git a/code/one-designer/packages/report-renderer/tsconfig.json b/code/one-designer/packages/report-renderer/tsconfig.json new file mode 100644 index 00000000..23cc1372 --- /dev/null +++ b/code/one-designer/packages/report-renderer/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/code/one-designer/packages/report-renderer/tsconfig.test.json b/code/one-designer/packages/report-renderer/tsconfig.test.json new file mode 100644 index 00000000..6cefc694 --- /dev/null +++ b/code/one-designer/packages/report-renderer/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "*.config.ts"] +} diff --git a/code/one-designer/packages/report-renderer/tsup.config.ts b/code/one-designer/packages/report-renderer/tsup.config.ts new file mode 100644 index 00000000..be47bf93 --- /dev/null +++ b/code/one-designer/packages/report-renderer/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + target: "es2022", +}) diff --git a/code/one-designer/pnpm-lock.yaml b/code/one-designer/pnpm-lock.yaml new file mode 100644 index 00000000..3d725f2d --- /dev/null +++ b/code/one-designer/pnpm-lock.yaml @@ -0,0 +1,2569 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.20.4 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.28)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.4)(jsdom@25.0.1) + + apps/demo: + dependencies: + '@report/core': + specifier: workspace:* + version: link:../../packages/report-core + '@report/designer': + specifier: workspace:* + version: link:../../packages/report-designer + '@report/designer-vue': + specifier: workspace:* + version: link:../../packages/report-designer-vue + '@report/layout': + specifier: workspace:* + version: link:../../packages/report-layout + '@report/renderer': + specifier: workspace:* + version: link:../../packages/report-renderer + vue: + specifier: ^3.5.13 + version: 3.5.43(typescript@5.9.3) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.4(vite@5.4.21(@types/node@22.20.4))(vue@3.5.43(typescript@5.9.3)) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^5.4.11 + version: 5.4.21(@types/node@22.20.4) + + packages/report-core: + devDependencies: + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.28)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.4)(jsdom@25.0.1) + + packages/report-designer: + dependencies: + '@report/core': + specifier: workspace:* + version: link:../report-core + '@report/layout': + specifier: workspace:* + version: link:../report-layout + devDependencies: + '@report/renderer': + specifier: workspace:* + version: link:../report-renderer + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.28)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.4)(jsdom@25.0.1) + + packages/report-designer-vue: + dependencies: + '@report/core': + specifier: workspace:* + version: link:../report-core + '@report/designer': + specifier: workspace:* + version: link:../report-designer + '@report/layout': + specifier: workspace:* + version: link:../report-layout + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.4(vite@5.4.21(@types/node@22.20.4))(vue@3.5.43(typescript@5.9.3)) + '@vue/test-utils': + specifier: ^2.4.6 + version: 2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3)) + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^5.4.11 + version: 5.4.21(@types/node@22.20.4) + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.4)(jsdom@25.0.1) + vue: + specifier: ^3.5.13 + version: 3.5.43(typescript@5.9.3) + vue-tsc: + specifier: ^2.1.10 + version: 2.2.12(typescript@5.9.3) + + packages/report-expression: + dependencies: + '@report/core': + specifier: workspace:* + version: link:../report-core + devDependencies: + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.28)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.4)(jsdom@25.0.1) + + packages/report-layout: + dependencies: + '@report/core': + specifier: workspace:* + version: link:../report-core + '@report/expression': + specifier: workspace:* + version: link:../report-expression + devDependencies: + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.28)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.4)(jsdom@25.0.1) + + packages/report-renderer: + dependencies: + '@report/core': + specifier: workspace:* + version: link:../report-core + '@report/layout': + specifier: workspace:* + version: link:../report-layout + devDependencies: + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.28)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.4)(jsdom@25.0.1) + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@one-ini/wasm@0.2.1': + resolution: {integrity: sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==} + + '@rollup/rollup-android-arm-eabi@4.63.4': + resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.4': + resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.4': + resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.4': + resolution: {integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.4': + resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.4': + resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + resolution: {integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + resolution: {integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.4': + resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + resolution: {integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.4': + resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + resolution: {integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + resolution: {integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + resolution: {integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.4': + resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.4': + resolution: {integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.4': + resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.4': + resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.4': + resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.4': + resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + '@vue/test-utils@2.5.1': + resolution: {integrity: sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + + abbrev@5.0.0: + resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + editorconfig@3.0.2: + resolution: {integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==} + engines: {node: '>=20'} + hasBin: true + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-beautify@2.0.3: + resolution: {integrity: sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nopt@10.0.1: + resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.63.4: + resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-component-type-helpers@3.3.11: + resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==} + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@one-ini/wasm@0.2.1': {} + + '@rollup/rollup-android-arm-eabi@4.63.4': + optional: true + + '@rollup/rollup-android-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-x64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.4': + optional: true + + '@types/estree@1.0.9': {} + + '@types/node@22.20.4': + dependencies: + undici-types: 6.21.0 + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@22.20.4))(vue@3.5.43(typescript@5.9.3))': + dependencies: + vite: 5.4.21(@types/node@22.20.4) + vue: 3.5.43(typescript@5.9.3) + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.20.4))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.20.4) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.43 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + '@vue/test-utils@2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@vue/compiler-dom': 3.5.43 + js-beautify: 2.0.3 + vue: 3.5.43(typescript@5.9.3) + vue-component-type-helpers: 3.3.11 + optionalDependencies: + '@vue/server-renderer': 3.5.43 + + abbrev@5.0.0: {} + + acorn@8.18.0: {} + + agent-base@7.1.4: {} + + alien-signals@1.0.13: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@14.0.3: {} + + commander@4.1.1: {} + + confbox@0.1.8: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + consola@3.4.2: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + editorconfig@3.0.2: + dependencies: + '@one-ini/wasm': 0.2.1 + commander: 14.0.3 + minimatch: 10.2.6 + semver: 7.8.5 + + entities@6.0.1: {} + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.63.4 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ini@1.3.8: {} + + is-potential-custom-element-name@1.0.1: {} + + joycon@3.1.1: {} + + js-beautify@2.0.3: + dependencies: + config-chain: 1.1.13 + editorconfig: 3.0.2 + glob: 13.0.6 + js-cookie: 3.0.8 + nopt: 10.0.1 + + js-cookie@3.0.8: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + + minipass@7.1.3: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.19: {} + + nopt@10.0.1: + dependencies: + abbrev: 5.0.0 + + nwsapi@2.2.28: {} + + object-assign@4.1.1: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-browserify@1.0.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.28): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.28 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proto-list@1.2.4: {} + + punycode@2.3.1: {} + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rollup@4.63.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.4 + '@rollup/rollup-android-arm64': 4.63.4 + '@rollup/rollup-darwin-arm64': 4.63.4 + '@rollup/rollup-darwin-x64': 4.63.4 + '@rollup/rollup-freebsd-arm64': 4.63.4 + '@rollup/rollup-freebsd-x64': 4.63.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.4 + '@rollup/rollup-linux-arm-musleabihf': 4.63.4 + '@rollup/rollup-linux-arm64-gnu': 4.63.4 + '@rollup/rollup-linux-arm64-musl': 4.63.4 + '@rollup/rollup-linux-loong64-gnu': 4.63.4 + '@rollup/rollup-linux-loong64-musl': 4.63.4 + '@rollup/rollup-linux-ppc64-gnu': 4.63.4 + '@rollup/rollup-linux-ppc64-musl': 4.63.4 + '@rollup/rollup-linux-riscv64-gnu': 4.63.4 + '@rollup/rollup-linux-riscv64-musl': 4.63.4 + '@rollup/rollup-linux-s390x-gnu': 4.63.4 + '@rollup/rollup-linux-x64-gnu': 4.63.4 + '@rollup/rollup-linux-x64-musl': 4.63.4 + '@rollup/rollup-openbsd-x64': 4.63.4 + '@rollup/rollup-openharmony-arm64': 4.63.4 + '@rollup/rollup-win32-arm64-msvc': 4.63.4 + '@rollup/rollup-win32-ia32-msvc': 4.63.4 + '@rollup/rollup-win32-x64-gnu': 4.63.4 + '@rollup/rollup-win32-x64-msvc': 4.63.4 + fsevents: 2.3.3 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.8.5: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + symbol-tree@3.2.4: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.28)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.28) + resolve-from: 5.0.0 + rollup: 4.63.4 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.28 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + vite-node@2.1.9(@types/node@22.20.4): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.20.4) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.20.4): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.28 + rollup: 4.63.4 + optionalDependencies: + '@types/node': 22.20.4 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.20.4)(jsdom@25.0.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.20.4)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.20.4) + vite-node: 2.1.9(@types/node@22.20.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.4 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vscode-uri@3.2.0: {} + + vue-component-type-helpers@3.3.11: {} + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue@3.5.43(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + optionalDependencies: + typescript: 5.9.3 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/code/one-designer/pnpm-workspace.yaml b/code/one-designer/pnpm-workspace.yaml new file mode 100644 index 00000000..759f4317 --- /dev/null +++ b/code/one-designer/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +packages: + - "apps/*" + - "packages/*" + - "packages/plugins/*" + - "examples/*" + +allowBuilds: + esbuild: true diff --git a/code/one-designer/s.yaml b/code/one-designer/s.yaml new file mode 100644 index 00000000..031e1e7a --- /dev/null +++ b/code/one-designer/s.yaml @@ -0,0 +1,61 @@ +- generic [ref=e3]: + - banner [ref=e4]: + - generic [ref=e5]: Web Report Designer · Demo + - button "🌙 暗色" [ref=e6] [cursor=pointer] + - main [ref=e7]: + - generic [ref=e8]: + - toolbar "工具栏" [ref=e9]: + - generic [ref=e10]: + - button "撤销" [disabled] [ref=e11] + - button "重做" [disabled] [ref=e12] + - generic [ref=e13]: + - button "缩小" [ref=e14] [cursor=pointer]: − + - generic [ref=e15]: 100% + - button "放大" [ref=e16] [cursor=pointer]: + + - button "预览" [ref=e18] [cursor=pointer] + - generic [ref=e19]: + - complementary "工具箱" [ref=e20]: + - generic [ref=e21]: 元素 + - button "文本" [ref=e22] [cursor=pointer] + - button "矩形" [ref=e23] [cursor=pointer] + - generic [ref=e24]: + - generic [ref=e25]: mm + - generic [aria-hidden] [ref=e26]: + - generic [ref=e27]: "0" + - generic [ref=e32]: "20" + - generic [ref=e37]: "40" + - generic [ref=e42]: "60" + - generic [ref=e47]: "80" + - generic [ref=e52]: "100" + - generic [ref=e57]: "120" + - generic [ref=e62]: "140" + - generic [ref=e67]: "160" + - generic [ref=e72]: "180" + - generic [ref=e77]: "200" + - generic [aria-hidden] [ref=e81]: + - generic [ref=e82]: "0" + - generic [ref=e87]: "20" + - generic [ref=e92]: "40" + - generic [ref=e97]: "60" + - generic [ref=e102]: "80" + - generic [ref=e107]: "100" + - generic [ref=e112]: "120" + - generic [ref=e117]: "140" + - generic [ref=e122]: "160" + - generic [ref=e127]: "180" + - generic [ref=e132]: "200" + - generic [ref=e137]: "220" + - generic [ref=e142]: "240" + - generic [ref=e147]: "260" + - generic [ref=e152]: "280" + - generic [ref=e159]: + - generic [ref=e160]: 销售出库单 + - generic [ref=e163]: 客户:示例公司 + - generic [ref=e165]: 日期:2025-01-01 + - generic [ref=e167]: 在左侧工具箱添加元素,点击画布元素可在右侧编辑属性,工具栏支持撤销/重做与缩放。 + - complementary "属性面板" [ref=e169]: + - generic [ref=e170]: 未选中元素 + - status [ref=e171]: + - generic [ref=e172]: 缩放 100% + - generic [ref=e173]: 单位 mm + - generic [ref=e174]: 元素 5 \ No newline at end of file diff --git a/code/one-designer/s2.yaml b/code/one-designer/s2.yaml new file mode 100644 index 00000000..cdddc2e2 --- /dev/null +++ b/code/one-designer/s2.yaml @@ -0,0 +1,76 @@ +- generic [ref=e3]: + - banner [ref=e4]: + - generic [ref=e5]: Web Report Designer · Demo + - button "🌙 暗色" [ref=e6] [cursor=pointer] + - main [ref=e7]: + - generic [ref=e8]: + - toolbar "工具栏" [ref=e9]: + - generic [ref=e10]: + - button "撤销" [ref=e11] [cursor=pointer] + - button "重做" [disabled] [ref=e12] + - generic [ref=e13]: + - button "缩小" [ref=e14] [cursor=pointer]: − + - generic [ref=e15]: 100% + - button "放大" [ref=e16] [cursor=pointer]: + + - button "预览" [ref=e18] [cursor=pointer] + - generic [ref=e19]: + - complementary "工具箱" [ref=e20]: + - generic [ref=e21]: 元素 + - button "文本" [ref=e22] [cursor=pointer] + - button "矩形" [ref=e23] [cursor=pointer] + - generic [ref=e24]: + - generic [ref=e25]: mm + - generic [aria-hidden] [ref=e26]: + - generic [ref=e27]: "0" + - generic [ref=e32]: "20" + - generic [ref=e37]: "40" + - generic [ref=e42]: "60" + - generic [ref=e47]: "80" + - generic [ref=e52]: "100" + - generic [ref=e57]: "120" + - generic [ref=e62]: "140" + - generic [ref=e67]: "160" + - generic [ref=e72]: "180" + - generic [ref=e77]: "200" + - generic [aria-hidden] [ref=e81]: + - generic [ref=e82]: "0" + - generic [ref=e87]: "20" + - generic [ref=e92]: "40" + - generic [ref=e97]: "60" + - generic [ref=e102]: "80" + - generic [ref=e107]: "100" + - generic [ref=e112]: "120" + - generic [ref=e117]: "140" + - generic [ref=e122]: "160" + - generic [ref=e127]: "180" + - generic [ref=e132]: "200" + - generic [ref=e137]: "220" + - generic [ref=e142]: "240" + - generic [ref=e147]: "260" + - generic [ref=e152]: "280" + - generic [ref=e159]: + - generic [ref=e160]: 销售出库单 + - generic [ref=e163]: 客户:示例公司 + - generic [ref=e165]: 日期:2025-01-01 + - generic [ref=e167]: 在左侧工具箱添加元素,点击画布元素可在右侧编辑属性,工具栏支持撤销/重做与缩放。 + - complementary "属性面板" [ref=e169]: + - generic [ref=e175]: 属性 + - generic [ref=e176]: + - generic [ref=e177]: ID + - generic [ref=e178]: title-1 + - generic [ref=e179]: + - generic [ref=e180]: 类型 + - generic [ref=e181]: text + - generic [ref=e182]: + - generic [ref=e183]: 位置 + - generic [ref=e184]: x36 y19 + - generic [ref=e185]: + - generic [ref=e186]: 尺寸 + - generic [ref=e187]: 120 × 12 + - generic [ref=e188]: + - generic [ref=e189]: 文本 + - textbox "文本" [ref=e190]: 销售出库单 + - status [ref=e171]: + - generic [ref=e172]: 缩放 100% + - generic [ref=e173]: 单位 mm + - generic [ref=e174]: 元素 5 \ No newline at end of file diff --git a/code/one-designer/s3.yaml b/code/one-designer/s3.yaml new file mode 100644 index 00000000..75a0b309 --- /dev/null +++ b/code/one-designer/s3.yaml @@ -0,0 +1,76 @@ +- generic [ref=e3]: + - banner [ref=e4]: + - generic [ref=e5]: Web Report Designer · Demo + - button "🌙 暗色" [ref=e6] [cursor=pointer] + - main [ref=e7]: + - generic [ref=e8]: + - toolbar "工具栏" [ref=e9]: + - generic [ref=e10]: + - button "撤销" [active] [ref=e11] [cursor=pointer] + - button "重做" [ref=e12] [cursor=pointer] + - generic [ref=e13]: + - button "缩小" [ref=e14] [cursor=pointer]: − + - generic [ref=e15]: 100% + - button "放大" [ref=e16] [cursor=pointer]: + + - button "预览" [ref=e18] [cursor=pointer] + - generic [ref=e19]: + - complementary "工具箱" [ref=e20]: + - generic [ref=e21]: 元素 + - button "文本" [ref=e22] [cursor=pointer] + - button "矩形" [ref=e23] [cursor=pointer] + - generic [ref=e24]: + - generic [ref=e25]: mm + - generic [aria-hidden] [ref=e26]: + - generic [ref=e27]: "0" + - generic [ref=e32]: "20" + - generic [ref=e37]: "40" + - generic [ref=e42]: "60" + - generic [ref=e47]: "80" + - generic [ref=e52]: "100" + - generic [ref=e57]: "120" + - generic [ref=e62]: "140" + - generic [ref=e67]: "160" + - generic [ref=e72]: "180" + - generic [ref=e77]: "200" + - generic [aria-hidden] [ref=e81]: + - generic [ref=e82]: "0" + - generic [ref=e87]: "20" + - generic [ref=e92]: "40" + - generic [ref=e97]: "60" + - generic [ref=e102]: "80" + - generic [ref=e107]: "100" + - generic [ref=e112]: "120" + - generic [ref=e117]: "140" + - generic [ref=e122]: "160" + - generic [ref=e127]: "180" + - generic [ref=e132]: "200" + - generic [ref=e137]: "220" + - generic [ref=e142]: "240" + - generic [ref=e147]: "260" + - generic [ref=e152]: "280" + - generic [ref=e159]: + - generic [ref=e160]: 销售出库单 + - generic [ref=e163]: 客户:示例公司 + - generic [ref=e165]: 日期:2025-01-01 + - generic [ref=e167]: 在左侧工具箱添加元素,点击画布元素可在右侧编辑属性,工具栏支持撤销/重做与缩放。 + - complementary "属性面板" [ref=e169]: + - generic [ref=e175]: 属性 + - generic [ref=e176]: + - generic [ref=e177]: ID + - generic [ref=e178]: title-1 + - generic [ref=e179]: + - generic [ref=e180]: 类型 + - generic [ref=e181]: text + - generic [ref=e182]: + - generic [ref=e183]: 位置 + - generic [ref=e184]: x23 y13 + - generic [ref=e185]: + - generic [ref=e186]: 尺寸 + - generic [ref=e187]: 120 × 12 + - generic [ref=e188]: + - generic [ref=e189]: 文本 + - textbox "文本" [ref=e190]: 销售出库单 + - status [ref=e171]: + - generic [ref=e172]: 缩放 100% + - generic [ref=e173]: 单位 mm + - generic [ref=e174]: 元素 5 \ No newline at end of file diff --git a/code/one-designer/tsconfig.base.json b/code/one-designer/tsconfig.base.json new file mode 100644 index 00000000..a4943746 --- /dev/null +++ b/code/one-designer/tsconfig.base.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": false, + + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/code/one-designer/v1.png b/code/one-designer/v1.png new file mode 100644 index 00000000..1eeb9f3b Binary files /dev/null and b/code/one-designer/v1.png differ