├── .envrc ├── apps ├── web │ ├── README.md │ ├── src │ │ ├── lib │ │ │ ├── function.ts │ │ │ ├── type.ts │ │ │ ├── actor │ │ │ │ ├── index.ts │ │ │ │ ├── worker-connection.ts │ │ │ │ ├── model.ts │ │ │ │ ├── actor.ts │ │ │ │ └── remote.ts │ │ │ ├── browser.ts │ │ │ ├── url.ts │ │ │ ├── logger.ts │ │ │ ├── entry.ts │ │ │ ├── string-compressor.ts │ │ │ ├── theme.ts │ │ │ ├── json.ts │ │ │ ├── file.ts │ │ │ ├── error.ts │ │ │ ├── copy-to-clipboard.ts │ │ │ ├── result.ts │ │ │ ├── json-parser.ts │ │ │ └── context.ts │ │ ├── core │ │ │ ├── index.ts │ │ │ ├── xlsx.ts │ │ │ ├── html.ts │ │ │ ├── actor.ts │ │ │ ├── model.ts │ │ │ └── create-table.ts │ │ ├── vite-env.d.ts │ │ ├── app.css │ │ ├── tables-worker.ts │ │ ├── html-table-page.svelte │ │ ├── init.ts │ │ ├── layout.svelte │ │ ├── theme-picker.svelte │ │ ├── download-table-page.svelte │ │ ├── theme.svelte.ts │ │ ├── main.ts │ │ ├── model.ts │ │ └── main-page.svelte │ ├── .vscode │ │ └── extensions.json │ ├── svelte.config.js │ ├── public │ │ ├── deduplication.json │ │ ├── test.json │ │ ├── vite.svg │ │ └── company.json │ ├── .gitignore │ ├── vite.config.ts │ ├── CHANGELOG.md │ ├── tsconfig.json │ ├── index.html │ └── package.json └── docs │ ├── README.md │ ├── src │ ├── i18n.ts │ ├── components │ │ ├── shadow │ │ │ ├── index.ts │ │ │ ├── shadow-root.svelte │ │ │ └── shadow-host.svelte │ │ ├── npm.astro │ │ ├── custom-head.astro │ │ └── header-with-links.astro │ ├── env.d.ts │ ├── content │ │ ├── config.ts │ │ └── docs │ │ │ ├── block-to-xlsx.mdx │ │ │ └── index.mdx │ └── astor.svelte.ts │ ├── svelte.config.js │ ├── tsconfig.json │ ├── .gitignore │ ├── package.json │ ├── public │ └── favicon.svg │ └── astro.config.mjs ├── packages ├── core │ ├── src │ │ ├── block-to-ascii │ │ │ ├── index.ts │ │ │ ├── __fixtures__ │ │ │ │ └── different-headers.json │ │ │ ├── block-to-ascii.test.ts │ │ │ └── block-to-ascii.ts │ │ ├── block │ │ │ ├── index.ts │ │ │ ├── row.ts │ │ │ ├── block.ts │ │ │ └── block.test.ts │ │ ├── ascii-to-block │ │ │ ├── index.ts │ │ │ ├── ascii-to-block.test.ts │ │ │ ├── ascii-to-block.ts │ │ │ └── model.ts │ │ ├── json-to-table │ │ │ ├── index.ts │ │ │ ├── __fixtures__ │ │ │ │ ├── simple-indexes-deduplication.json │ │ │ │ ├── simple-headers-duplication.json │ │ │ │ ├── parsing-error.json │ │ │ │ ├── different-headers.json │ │ │ │ ├── uniq-headers.json │ │ │ │ ├── empty-arrays.json │ │ │ │ └── wrong-sizes.json │ │ │ ├── proportional-resize-guard.ts │ │ │ ├── properties-stabilizer.ts │ │ │ ├── json-to-table.ts │ │ │ ├── table.ts │ │ │ └── json-to-table.test.ts │ │ ├── lib │ │ │ ├── array.ts │ │ │ ├── object.ts │ │ │ ├── guards.ts │ │ │ ├── html.ts │ │ │ ├── math.ts │ │ │ ├── json.ts │ │ │ ├── binary-tree.ts │ │ │ └── matrix.ts │ │ ├── block-to-html.ts │ │ ├── json-table.ts │ │ └── block-matrix.ts │ ├── CHANGELOG.md │ ├── tsconfig.json │ ├── .gitignore │ ├── package.json │ └── README.md └── block-to-xlsx │ ├── CHANGELOG.md │ ├── tsconfig.json │ ├── .gitignore │ ├── README.md │ ├── package.json │ └── src │ ├── block-to-xlsx.ts │ └── block-to-xlsx.test.ts ├── .gitignore ├── .changeset ├── red-pianos-help.md ├── README.md └── config.json ├── pnpm-workspace.yaml ├── flake.nix ├── turbo.json ├── mkfilex ├── package.json ├── LICENSE ├── .github └── workflows │ └── release.yml ├── README.md └── flake.lock /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | # Web 2 | -------------------------------------------------------------------------------- /apps/docs/README.md: -------------------------------------------------------------------------------- 1 | # Docs 2 | -------------------------------------------------------------------------------- /apps/docs/src/i18n.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_LOCALE = "en"; 2 | -------------------------------------------------------------------------------- /apps/web/src/lib/function.ts: -------------------------------------------------------------------------------- 1 | export function noop(): void {} 2 | -------------------------------------------------------------------------------- /packages/core/src/block-to-ascii/index.ts: -------------------------------------------------------------------------------- 1 | export * from './block-to-ascii.js' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .direnv/ 2 | 3 | node_modules 4 | 5 | .turbo 6 | 7 | *.tsbuildinfo 8 | -------------------------------------------------------------------------------- /apps/web/src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./model"; 2 | export * from "./actor"; 3 | -------------------------------------------------------------------------------- /apps/web/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["svelte.svelte-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/src/block/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./row.js"; 2 | export * from "./block.js"; 3 | -------------------------------------------------------------------------------- /apps/docs/src/components/shadow/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ShadowHost } from './shadow-host.svelte' 2 | -------------------------------------------------------------------------------- /apps/web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/core/src/ascii-to-block/index.ts: -------------------------------------------------------------------------------- 1 | export * from './model.js' 2 | export * from './ascii-to-block.js' 3 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./table.js"; 2 | export * from "./json-to-table.js"; 3 | -------------------------------------------------------------------------------- /apps/docs/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /.changeset/red-pianos-help.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@json-table/core": minor 3 | --- 4 | 5 | Remove unused code from `lib/json` and `lib/ord` 6 | -------------------------------------------------------------------------------- /apps/web/src/lib/type.ts: -------------------------------------------------------------------------------- 1 | declare const brand: unique symbol 2 | 3 | export type Brand = Base & { [brand]: Name } 4 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/__fixtures__/simple-indexes-deduplication.json: -------------------------------------------------------------------------------- 1 | { 2 | "a": [1, 4, 7], 3 | "b": [2, 5, 8], 4 | "c": [3, 6, 9] 5 | } -------------------------------------------------------------------------------- /apps/web/src/lib/actor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './model.js' 2 | export * from './actor.js' 3 | export * from './worker-connection.js' 4 | export * from './remote.js' 5 | -------------------------------------------------------------------------------- /apps/web/src/lib/browser.ts: -------------------------------------------------------------------------------- 1 | export function createPage(content: string) { 2 | const win = window.open('') 3 | win?.document.write(content) 4 | return win 5 | } 6 | -------------------------------------------------------------------------------- /packages/core/src/lib/array.ts: -------------------------------------------------------------------------------- 1 | export const array = ( 2 | count: number, 3 | factory: (index: number) => R 4 | ): R[] => Array.from(new Array(count), (_, i) => factory(i)) 5 | -------------------------------------------------------------------------------- /apps/web/src/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "daisyui" { 3 | themes: light --default, dim --prefersdark; 4 | } 5 | @source "../node_modules/@sjsf/daisyui5-theme/dist"; 6 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/__fixtures__/simple-headers-duplication.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "a": 1, "b": 2, "c": 3 }, 3 | { "a": 4, "b": 5, "c": 6 }, 4 | { "a": 7, "b": 8, "c": 9 } 5 | ] -------------------------------------------------------------------------------- /apps/web/src/lib/url.ts: -------------------------------------------------------------------------------- 1 | export const isValidUrl = (urlString: string) => { 2 | try { 3 | return Boolean(new URL(urlString)); 4 | } catch (e) { 5 | return false; 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /apps/web/src/tables-worker.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from '@/lib/context' 2 | 3 | import { startTablesFactoryWorker } from './core' 4 | 5 | startTablesFactoryWorker(createContext()) 6 | -------------------------------------------------------------------------------- /apps/web/src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | debug(text: string): void; 3 | info(text: string): void; 4 | warn(text: string): void; 5 | error(text: string): void; 6 | } 7 | 8 | -------------------------------------------------------------------------------- /apps/web/src/lib/entry.ts: -------------------------------------------------------------------------------- 1 | export type Entry = [string, V] 2 | 3 | export function transformValue(map: (value: V) => R) { 4 | return ([key, value]: Entry): Entry => [key, map(value)] 5 | } 6 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - apps/* 3 | - packages/* 4 | catalog: 5 | vite: npm:rolldown-vite@^7.1.20 6 | vitest: ^4.0.7 7 | onlyBuiltDependencies: 8 | - '@tailwindcss/oxide' 9 | - sharp 10 | -------------------------------------------------------------------------------- /apps/docs/src/components/shadow/shadow-root.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | {@render children?.()} 8 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/__fixtures__/parsing-error.json: -------------------------------------------------------------------------------- 1 | { 2 | "weather": [ 3 | { 4 | "id": 800, 5 | "main": "Clear", 6 | "description": "clear sky", 7 | "icon": "01n" 8 | } 9 | ] 10 | } -------------------------------------------------------------------------------- /packages/block-to-xlsx/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @json-table/block-to-xlsx 2 | 3 | ## 0.1.0 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [[`a089b62`](https://github.com/x0k/json-table/commit/a089b628379acfef7bf1b56a6e85235bb442ec3a)]: 8 | - @json-table/core@0.1.0 9 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @json-table/core 2 | 3 | ## 0.1.0 4 | 5 | ### Minor Changes 6 | 7 | - [`a089b62`](https://github.com/x0k/json-table/commit/a089b628379acfef7bf1b56a6e85235bb442ec3a) Thanks [@x0k](https://github.com/x0k)! - Add `makeBlockFactory` function 8 | -------------------------------------------------------------------------------- /apps/docs/src/components/npm.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { PackageManagers, type PackageManagersProps } from 'starlight-package-managers' 3 | 4 | export type Props = PackageManagersProps 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /apps/web/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/block-to-ascii/__fixtures__/different-headers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "character_id": "5428010618020694593", 4 | "item_id": "95" 5 | }, 6 | { 7 | "character_id": "5428010618020694593", 8 | "item_id": "101", 9 | "stack_count": "4" 10 | } 11 | ] -------------------------------------------------------------------------------- /packages/core/src/json-to-table/__fixtures__/different-headers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "character_id": "5428010618020694593", 4 | "item_id": "95" 5 | }, 6 | { 7 | "character_id": "5428010618020694593", 8 | "item_id": "101", 9 | "stack_count": "4" 10 | } 11 | ] -------------------------------------------------------------------------------- /apps/docs/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsLoader } from '@astrojs/starlight/loaders' 3 | import { docsSchema } from '@astrojs/starlight/schema'; 4 | 5 | export const collections = { 6 | docs: defineCollection({ schema: docsSchema(), loader: docsLoader() }), 7 | }; 8 | -------------------------------------------------------------------------------- /packages/core/src/lib/object.ts: -------------------------------------------------------------------------------- 1 | export function isObject(value: unknown): value is object { 2 | return typeof value === "object" && value !== null; 3 | } 4 | 5 | export function isRecord( 6 | value: unknown 7 | ): value is Record { 8 | return isObject(value) && !Array.isArray(value); 9 | } 10 | -------------------------------------------------------------------------------- /apps/docs/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from "@astrojs/svelte"; 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | compilerOptions: { 8 | // runes: true, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/__fixtures__/uniq-headers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "name of the ComponentStatus", 4 | "in": "path", 5 | "name": "name", 6 | "required": true, 7 | "type": "string", 8 | "uniqueItems": true 9 | }, 10 | { 11 | "$ref": "#/parameters/pretty-tJGM1-ng" 12 | } 13 | ] -------------------------------------------------------------------------------- /apps/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": [ 7 | "src/*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | ".astro/types.d.ts", 13 | "**/*" 14 | ], 15 | "exclude": [ 16 | "dist" 17 | ] 18 | } -------------------------------------------------------------------------------- /apps/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /apps/web/src/lib/string-compressor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | compressToEncodedURIComponent, 3 | decompressFromEncodedURIComponent, 4 | } from "lz-string"; 5 | 6 | export function makeURIComponentCompressor() { 7 | return { 8 | decompress: decompressFromEncodedURIComponent, 9 | compress: compressToEncodedURIComponent, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/proportional-resize-guard.ts: -------------------------------------------------------------------------------- 1 | import type { ProportionalResizeGuard } from '../json-table.js'; 2 | 3 | export function makeProportionalResizeGuard( 4 | threshold: number 5 | ): ProportionalResizeGuard { 6 | return (lcmValue: number, maxValue: number) => 7 | (lcmValue - maxValue) / maxValue <= threshold; 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/public/deduplication.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | { "a": [1.1], "b": [1.2], "c": [1.3] }, 4 | { "a": [1.4], "b": [1.5], "c": [1.6] }, 5 | { "a": [1.7], "b": [1.8], "c": [1.9] } 6 | ], [ 7 | { "a": [2.1], "b": [2.2], "c": [2.3] }, 8 | { "a": [2.4], "b": [2.5], "c": [2.6] }, 9 | { "a": [2.7], "b": [2.8], "c": [2.9] } 10 | ] 11 | ] -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@total-typescript/tsconfig/tsc/dom/library-monorepo", 3 | "compilerOptions": { 4 | "moduleResolution": "NodeNext", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | }, 8 | "include": [ 9 | "src" 10 | ], 11 | "exclude": [ 12 | "**/*.test.*", 13 | "**/*.spec.*" 14 | ] 15 | } -------------------------------------------------------------------------------- /apps/web/src/html-table-page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | {#await content} 6 |

Loading...

7 | {:then content} 8 | 9 | {:catch error} 10 |

{error}

11 | {/await} 12 | -------------------------------------------------------------------------------- /packages/block-to-xlsx/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@total-typescript/tsconfig/tsc/dom/library-monorepo", 3 | "compilerOptions": { 4 | "moduleResolution": "NodeNext", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | }, 8 | "include": [ 9 | "src" 10 | ], 11 | "exclude": [ 12 | "**/*.test.*", 13 | "**/*.spec.*" 14 | ] 15 | } -------------------------------------------------------------------------------- /packages/core/src/lib/guards.ts: -------------------------------------------------------------------------------- 1 | export type Nothing = null | undefined 2 | 3 | export type Falsy = 0 | '' | false | Nothing 4 | 5 | export function isDefined(value: T | undefined): value is T { 6 | return value !== undefined 7 | } 8 | 9 | export function isSomething(value: T | Nothing): value is T { 10 | return value !== null && value !== undefined 11 | } 12 | -------------------------------------------------------------------------------- /packages/core/src/lib/html.ts: -------------------------------------------------------------------------------- 1 | export function escapeHtml(unsafe: string) { 2 | return unsafe.replace(/[&<"']/g, function (m) { 3 | switch (m) { 4 | case "&": 5 | return "&"; 6 | case "<": 7 | return "<"; 8 | case '"': 9 | return """; 10 | default: 11 | return "'"; 12 | } 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/core/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/block-to-xlsx/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/web/src/lib/theme.ts: -------------------------------------------------------------------------------- 1 | export enum Theme { 2 | System = "system", 3 | Light = "light", 4 | Dark = "dark", 5 | } 6 | 7 | export const THEME_TITLES: Record = { 8 | [Theme.System]: "System", 9 | [Theme.Light]: "Light", 10 | [Theme.Dark]: "Dark", 11 | } 12 | 13 | export const THEMES = Object.values(Theme); 14 | 15 | export type DarkOrLight = Theme.Dark | Theme.Light; 16 | -------------------------------------------------------------------------------- /apps/web/src/init.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from '@/lib/context'; 2 | import { makeURIComponentCompressor } from "@/lib/string-compressor"; 3 | 4 | import { createRemoteTablesFactory } from './core'; 5 | 6 | import TablesWorker from './tables-worker?worker' 7 | 8 | export const compressor = makeURIComponentCompressor(); 9 | 10 | export const appWorker = createRemoteTablesFactory(createContext(), TablesWorker) 11 | -------------------------------------------------------------------------------- /apps/web/src/layout.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |

JSON Table

{@render append?.()}
9 | {@render children()} 10 |
11 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/__fixtures__/empty-arrays.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "tasks": { 4 | "n": [ 5 | "UspYpi-8NwmZZR7FJprSb" 6 | ], 7 | "d": [], 8 | "a": [ 9 | "aCx8zMrOjqW6K55TMokHD" 10 | ] 11 | } 12 | }, 13 | { 14 | "tasks": { 15 | "n": [], 16 | "d": [], 17 | "a": [ 18 | "gwT5xfbxgkPCq_VDyoBO3" 19 | ] 20 | } 21 | } 22 | ] -------------------------------------------------------------------------------- /apps/docs/src/components/custom-head.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import StarlightHead from "@astrojs/starlight/components/Head.astro"; 3 | import VtbotStarlight from "astro-vtbot/components/starlight/Base.astro"; 4 | import PageOffset from "astro-vtbot/components/PageOffset.astro"; 5 | --- 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /apps/web/src/lib/json.ts: -------------------------------------------------------------------------------- 1 | export function stringify(obj: any, spaces?: number | string) { 2 | const seen = new WeakSet(); 3 | return JSON.stringify( 4 | obj, 5 | (_, value) => { 6 | if (typeof value === "object" && value !== null) { 7 | if (seen.has(value)) { 8 | return "[Circular]"; 9 | } 10 | seen.add(value); 11 | } 12 | return value; 13 | }, 14 | spaces 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | import { defineConfig } from "vite"; 4 | import { svelte } from "@sveltejs/vite-plugin-svelte"; 5 | import tailwindcss from "@tailwindcss/vite"; 6 | 7 | // https://vite.dev/config/ 8 | export default defineConfig({ 9 | base: "/json-table/", 10 | plugins: [svelte(), tailwindcss()], 11 | resolve: { 12 | alias: { 13 | "@": path.resolve(__dirname, "./src"), 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /apps/web/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # web 2 | 3 | ## 0.1.0 4 | 5 | ### Minor Changes 6 | 7 | - [`aa6f35f`](https://github.com/x0k/json-table/commit/aa6f35f43100b15d7c4f3120316238f4fce4b5d5) Thanks [@x0k](https://github.com/x0k)! - Use hash to share data 8 | 9 | ## 0.0.1 10 | 11 | ### Patch Changes 12 | 13 | - Updated dependencies [[`a089b62`](https://github.com/x0k/json-table/commit/a089b628379acfef7bf1b56a6e85235bb442ec3a)]: 14 | - @json-table/core@0.1.0 15 | - @json-table/block-to-xlsx@0.1.0 16 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@total-typescript/tsconfig/bundler/dom/app", 3 | "compilerOptions": { 4 | "moduleResolution": "Bundler", 5 | "rootDir": "src", 6 | "lib": [ 7 | "ES2022", 8 | "ESNext.Disposable" 9 | ], 10 | "types": [ 11 | "vite/client", 12 | ], 13 | "baseUrl": ".", 14 | "paths": { 15 | "@/*": [ 16 | "./src/*" 17 | ], 18 | } 19 | }, 20 | "include": [ 21 | "src/**/*.ts", 22 | "src/**/*.js", 23 | "src/**/*.svelte" 24 | ], 25 | } -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /apps/web/src/core/xlsx.ts: -------------------------------------------------------------------------------- 1 | import { Workbook } from "exceljs"; 2 | import type { Block } from "@json-table/core"; 3 | import { 4 | renderBlockOnWorksheet, 5 | type MakeWorkBookOptions, 6 | } from "@json-table/block-to-xlsx"; 7 | 8 | import type { Entry } from "@/lib/entry"; 9 | 10 | export function makeWorkBook( 11 | tables: Entry[], 12 | options?: MakeWorkBookOptions 13 | ): Workbook { 14 | const wb = new Workbook(); 15 | tables.forEach(([title, table]) => { 16 | renderBlockOnWorksheet(wb.addWorksheet(title), table, options); 17 | }); 18 | return wb; 19 | } 20 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "x0k/json-table" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [ 11 | [ 12 | "@json-table/*" 13 | ] 14 | ], 15 | "linked": [], 16 | "access": "public", 17 | "baseBranch": "main", 18 | "updateInternalDependencies": "patch", 19 | "ignore": [], 20 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { 21 | "onlyUpdatePeerDependentsWhenOutOfRange": true 22 | } 23 | } -------------------------------------------------------------------------------- /apps/web/src/lib/file.ts: -------------------------------------------------------------------------------- 1 | export function createXLSBlob(data: ArrayBuffer) { 2 | return new Blob([data], { 3 | type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8', 4 | }) 5 | } 6 | 7 | export function createFileURL(data: MediaSource | Blob) { 8 | return URL.createObjectURL(data) 9 | } 10 | 11 | export function makeDownloadFileByUrl(filename: string) { 12 | return (url: string) => { 13 | const a = document.createElement('a') 14 | a.setAttribute('href', url) 15 | a.setAttribute('download', filename) 16 | a.click() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.0", 6 | "scripts": { 7 | "dev": "astro dev", 8 | "build": " astro build", 9 | "preview": "astro preview", 10 | "check": "astro check" 11 | }, 12 | "devDependencies": { 13 | "@astrojs/check": "^0.9.5", 14 | "@astrojs/starlight": "^0.36.2", 15 | "@astrojs/svelte": "^7.2.1", 16 | "astro": "^5.15.3", 17 | "astro-vtbot": "^2.1.9", 18 | "sharp": "^0.34.4", 19 | "starlight-package-managers": "^0.11.1", 20 | "svelte": "^5.43.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/core/src/lib/math.ts: -------------------------------------------------------------------------------- 1 | export const sum = (a: number, b: number): number => a + b 2 | export const subtract = (a: number, b: number): number => a - b 3 | export const multiply = (a: number, b: number): number => a * b 4 | export const divide = (a: number, b: number): number => a / b 5 | 6 | export const max = (a: number, b: number): number => (a > b ? a : b) 7 | export const min = (a: number, b: number): number => (a < b ? a : b) 8 | 9 | export const gcd = (a: number, b: number): number => (a ? gcd(b % a, a) : b) 10 | export const lcm = (a: number, b: number): number => (a * b) / gcd(a, b) 11 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/__fixtures__/wrong-sizes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "options": { 4 | "reduceOptions": { 5 | "values": false 6 | } 7 | }, 8 | "pluginVersion": "7.3.1", 9 | "targets": [ 10 | { 11 | "expr": "loki_build_info", 12 | "format": "table" 13 | } 14 | ] 15 | }, 16 | { 17 | "options": { 18 | "reduceOptions": { 19 | "values": false 20 | } 21 | }, 22 | "pluginVersion": "7.3.1", 23 | "targets": [ 24 | { 25 | "expr": "sum(log_messages_total)" 26 | } 27 | ] 28 | } 29 | ] -------------------------------------------------------------------------------- /packages/core/src/lib/json.ts: -------------------------------------------------------------------------------- 1 | export type JSONPrimitiveExceptNull = string | number | boolean; 2 | 3 | export type JSONPrimitive = JSONPrimitiveExceptNull | null; 4 | 5 | export type JSONPrimitiveExceptNullLiterals = "string" | "number" | "boolean"; 6 | 7 | export type JSONRecord = { [k: string]: JSONValue }; 8 | 9 | export type JSONArray = JSONValue[]; 10 | 11 | export type JSONObject = JSONRecord | JSONArray; 12 | 13 | export type JSONValue = JSONPrimitive | JSONObject; 14 | 15 | export const isJsonPrimitive = ( 16 | value: JSONValue 17 | ): value is JSONPrimitive => value === null || typeof value !== "object"; 18 | -------------------------------------------------------------------------------- /apps/docs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | JSON Table 9 | 14 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /apps/web/src/lib/error.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from './json'; 2 | 3 | export function neverError(value: never, message: string) { 4 | return new Error(`${message}: ${value}`); 5 | } 6 | 7 | export function stringifyError(err: unknown): string { 8 | if (typeof err === "string") { 9 | return err; 10 | } 11 | if (err instanceof Error) { 12 | return err.message; 13 | } 14 | if (err && typeof err === "object") { 15 | if ("toString" in err && typeof err.toString === "function") { 16 | const str = err.toString(); 17 | if (str !== "[object Object]") { 18 | return str; 19 | } 20 | } 21 | return stringify(err); 22 | } 23 | return String(err); 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/public/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "string": "Hello, JSON!", 3 | "number": 42, 4 | "boolean": true, 5 | "nullValue": null, 6 | "array": [1, 2, 3, "four", false], 7 | "object": { 8 | "key1": "value1", 9 | "key2": 789, 10 | "key3": { 11 | "nestedKey": "nestedValue" 12 | } 13 | }, 14 | "nestedArray": [ 15 | { 16 | "name": "John", 17 | "age": 30, 18 | "isStudent": false 19 | }, 20 | { 21 | "name": "Alice", 22 | "age": 25, 23 | "isStudent": true 24 | } 25 | ], 26 | "escapedString": "This is a \"quoted\" string.", 27 | "unicodeString": "Unicode characters: \u00A9\uD83D\uDE00", 28 | "date": "2023-11-28T12:34:56.789Z" 29 | } 30 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-25.05"; 4 | mk.url = "github:x0k/mk"; 5 | }; 6 | outputs = 7 | { 8 | self, 9 | nixpkgs, 10 | mk, 11 | }: 12 | let 13 | system = "x86_64-linux"; 14 | pkgs = import nixpkgs { inherit system; }; 15 | in 16 | { 17 | devShells.${system} = { 18 | default = pkgs.mkShell { 19 | buildInputs = [ 20 | mk.packages.${system}.default 21 | pkgs.nodejs_24 22 | pkgs.pnpm 23 | ]; 24 | shellHook = '' 25 | source <(COMPLETE=bash mk) 26 | ''; 27 | }; 28 | }; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "check": { 5 | "dependsOn": [ 6 | "^build" 7 | ] 8 | }, 9 | "build": { 10 | "dependsOn": [ 11 | "check" 12 | ], 13 | "outputs": [ 14 | "dist/**" 15 | ] 16 | }, 17 | "dev": { 18 | "dependsOn": [ 19 | "^build" 20 | ], 21 | "persistent": true, 22 | "cache": false 23 | }, 24 | "test": { 25 | "dependsOn": [ 26 | "^build" 27 | ] 28 | }, 29 | "preview": { 30 | "persistent": true, 31 | "cache": false, 32 | "dependsOn": [ 33 | "build" 34 | ] 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /mkfilex: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -xe 4 | 5 | cs: 6 | pnpm changeset 7 | 8 | t: 9 | pnpm run test 10 | 11 | b: 12 | pnpm run build 13 | 14 | d: 15 | pnpm run dev 16 | 17 | docs/: 18 | pushd apps/docs 19 | d: 20 | pnpm run dev 21 | c: 22 | pnpm run check 23 | b: 24 | pnpm run build 25 | popd 26 | 27 | app/: 28 | pushd apps/web 29 | d: 30 | pnpm run dev 31 | c: 32 | pnpm run check 33 | b: 34 | pnpm run build 35 | popd 36 | 37 | c/: 38 | pushd packages/core 39 | b: 40 | pnpm run build 41 | t: 42 | pnpm run test 43 | popd 44 | 45 | x/: 46 | pushd packages/block-to-xlsx 47 | b: 48 | pnpm run build 49 | t: 50 | pnpm run test 51 | popd 52 | 53 | h: 54 | mk -P targets "*" 55 | -------------------------------------------------------------------------------- /packages/core/src/lib/binary-tree.ts: -------------------------------------------------------------------------------- 1 | export interface Node> { 2 | value: V 3 | left?: C 4 | right?: C 5 | } 6 | 7 | export type Tree> = T | undefined 8 | 9 | export function insert>(tree: Tree, node: N) { 10 | if (tree === undefined) { 11 | return node 12 | } 13 | if (node.value < tree.value) { 14 | tree.left = insert(tree.left, node) 15 | } else { 16 | tree.right = insert(tree.right, node) 17 | } 18 | return tree 19 | } 20 | 21 | export function traverse>(tree: Tree, cb: (node: N) => void) { 22 | if (tree !== undefined) { 23 | traverse(tree.left, cb) 24 | cb(tree) 25 | traverse(tree.right, cb) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/lib/copy-to-clipboard.ts: -------------------------------------------------------------------------------- 1 | function fallbackCopyTextToClipboard(text: string) { 2 | var textArea = document.createElement('textarea') 3 | textArea.value = text 4 | 5 | // Avoid scrolling to bottom 6 | textArea.style.top = '0' 7 | textArea.style.left = '0' 8 | textArea.style.position = 'fixed' 9 | 10 | document.body.appendChild(textArea) 11 | textArea.focus() 12 | textArea.select() 13 | 14 | try { 15 | return Promise.resolve(document.execCommand('copy')) 16 | } catch (err) { 17 | return Promise.reject(err) 18 | } finally { 19 | document.body.removeChild(textArea) 20 | } 21 | } 22 | 23 | export function copyTextToClipboard(text: string) { 24 | if (!navigator.clipboard) { 25 | return fallbackCopyTextToClipboard(text) 26 | } 27 | return navigator.clipboard.writeText(text).then(() => true) 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/lib/result.ts: -------------------------------------------------------------------------------- 1 | export interface Ok { 2 | ok: true; 3 | value: T; 4 | } 5 | 6 | export interface Err { 7 | ok: false; 8 | error: E; 9 | } 10 | 11 | export type Result = Ok | Err; 12 | 13 | export function isOk(result: Result): result is Ok { 14 | return result.ok; 15 | } 16 | 17 | export function isErr(result: Result): result is Err { 18 | return !result.ok; 19 | } 20 | 21 | export function ok(value: T): Ok { 22 | return { ok: true, value }; 23 | } 24 | 25 | export function err(error: E): Err { 26 | return { ok: false, error }; 27 | } 28 | 29 | export function unwrap(g: Generator, Result, T>) { 30 | let result = g.next(); 31 | while (!result.done && isOk(result.value)) { 32 | result = g.next(result.value.value); 33 | } 34 | return result.value; 35 | } 36 | -------------------------------------------------------------------------------- /apps/web/src/lib/json-parser.ts: -------------------------------------------------------------------------------- 1 | export enum JSONParseStatus { 2 | Ok = 'ok', 3 | Error = 'error', 4 | } 5 | 6 | export type JSONParseResult = 7 | | { 8 | status: JSONParseStatus.Ok 9 | data: T 10 | } 11 | | { 12 | status: JSONParseStatus.Error 13 | error: E 14 | } 15 | 16 | export function jsonTryParse( 17 | json: string 18 | ): JSONParseResult { 19 | try { 20 | return { 21 | status: JSONParseStatus.Ok, 22 | data: JSON.parse(json), 23 | } 24 | } catch (error) { 25 | return { 26 | status: JSONParseStatus.Error, 27 | error: error as E, 28 | } 29 | } 30 | } 31 | 32 | export function jsonSafeParse(json: string, defaultValue: T): T { 33 | const result = jsonTryParse(json) 34 | return result.status === JSONParseStatus.Ok ? result.data : defaultValue 35 | } 36 | -------------------------------------------------------------------------------- /packages/block-to-xlsx/README.md: -------------------------------------------------------------------------------- 1 | # @json-table/block-to-xlsx 2 | 3 | The [exceljs](https://github.com/exceljs/exceljs) based block to XLSX converter for [JSON Table](https://github.com/x0k/json-table). 4 | 5 | ## Install 6 | 7 | ```shell 8 | npm install @json-table/core @json-table/block-to-xlsx 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```typescript 14 | import { makeTableInPlaceBaker, makeTableFactory } from "@json-table/core/json-to-table"; 15 | import { renderBlockOnWorksheet } from "@json-table/block-to-xlsx"; 16 | import { Workbook } from "exceljs"; 17 | 18 | const cornerCellValue = "№"; 19 | const factory = makeTableFactory({ cornerCellValue }); 20 | const bake = makeTableInPlaceBaker({ cornerCellValue, head: true, indexes: true }); 21 | const wb = new Workbook(); 22 | renderBlockOnWorksheet(wb.addWorksheet("Table"), bake(factory(data))); 23 | ``` 24 | 25 | ## License 26 | 27 | MIT 28 | -------------------------------------------------------------------------------- /apps/web/src/lib/actor/worker-connection.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from "../context.js"; 2 | 3 | import type { Connection } from "./model.js"; 4 | 5 | export class WorkerConnection 6 | implements Connection 7 | { 8 | private handlers = new Set<(message: Incoming) => void>(); 9 | 10 | constructor(private readonly worker: Worker) {} 11 | 12 | start(ctx: Context) { 13 | this.worker.addEventListener( 14 | "message", 15 | (e) => { 16 | for (const handler of this.handlers) { 17 | handler(e.data); 18 | } 19 | }, 20 | ctx 21 | ); 22 | } 23 | 24 | send(message: Outgoing) { 25 | this.worker.postMessage(message); 26 | } 27 | 28 | onMessage(ctx: Context, handler: (message: Incoming) => void) { 29 | this.handlers.add(handler); 30 | ctx.onCancel(() => { 31 | this.handlers.delete(handler); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-table", 3 | "private": true, 4 | "author": "Roman Krasilnikov", 5 | "license": "MIT", 6 | "type": "module", 7 | "version": "0.0.0", 8 | "packageManager": "pnpm@10.15.1", 9 | "main": "index.js", 10 | "scripts": { 11 | "preinstall": "npx only-allow pnpm", 12 | "check": "turbo run check", 13 | "dev": "turbo run dev", 14 | "test": "turbo run test", 15 | "build": "turbo run build", 16 | "preview": "turbo run preview", 17 | "ci:build": "turbo run build test", 18 | "ci:version": "changeset version && pnpm install --no-frozen-lockfile" 19 | }, 20 | "keywords": [], 21 | "devDependencies": { 22 | "@changesets/changelog-github": "^0.5.1", 23 | "@changesets/cli": "^2.29.7", 24 | "@total-typescript/tsconfig": "^1.0.4", 25 | "only-allow": "^1.2.1", 26 | "publint": "^0.3.15", 27 | "turbo": "^2.6.0", 28 | "typescript": "^5.9.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/docs/src/components/shadow/shadow-host.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 |
33 | -------------------------------------------------------------------------------- /packages/core/src/lib/matrix.ts: -------------------------------------------------------------------------------- 1 | import { array } from './array.js' 2 | 3 | export type Matrix = T[][] 4 | 5 | export function matrix ( 6 | height: number, 7 | width: number, 8 | factory: (rowIndex: number) => R 9 | ) { 10 | return array(height, () => array(width, factory)) 11 | } 12 | 13 | export function transpose(matrix: Matrix): Matrix { 14 | return matrix[0]!.map((_, i) => matrix.map((x) => x[i]!)) 15 | } 16 | 17 | export function horizontalMirror(matrix: Matrix): Matrix { 18 | return matrix.map((row) => row.slice().reverse()) 19 | } 20 | 21 | export function verticalMirror(matrix: Matrix): Matrix { 22 | return matrix.slice().reverse() 23 | } 24 | 25 | export type TransformCell = ( 26 | value: T, 27 | index: number, 28 | rowIndex: number 29 | ) => R 30 | 31 | export function mapCell(transform: TransformCell) { 32 | return (matrix: Matrix): Matrix => 33 | matrix.map((row, i) => row.map((cell, j) => transform(cell, j, i))) 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-check --tsconfig ./tsconfig.json" 11 | }, 12 | "devDependencies": { 13 | "@sveltejs/vite-plugin-svelte": "^6.2.1", 14 | "@tailwindcss/vite": "^4.1.16", 15 | "@tsconfig/svelte": "^5.0.5", 16 | "daisyui": "^5.4.3", 17 | "svelte": "^5.43.3", 18 | "svelte-check": "^4.3.3", 19 | "tailwindcss": "^4.1.16", 20 | "vite": "catalog:", 21 | "vitest": "catalog:" 22 | }, 23 | "dependencies": { 24 | "@json-table/block-to-xlsx": "workspace:^", 25 | "@json-table/core": "workspace:^0.1.0", 26 | "@sjsf/ajv8-validator": "^3.0.0", 27 | "@sjsf/daisyui5-theme": "^3.0.0", 28 | "@sjsf/form": "^3.0.0", 29 | "ajv": "^8.17.1", 30 | "browser-fs-access": "^0.38.0", 31 | "exceljs": "^4.4.0", 32 | "lz-string": "^1.5.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/core/src/block-to-html.ts: -------------------------------------------------------------------------------- 1 | import { escapeHtml } from "./lib/html.js"; 2 | 3 | import { type Block, CellType } from "./json-table.js"; 4 | 5 | export function blockToHTML(model: Block) { 6 | const rows: string[] = []; 7 | let r = 0; 8 | let index = model.data.indexes[r]; 9 | for (let i = 0; i < model.height; i++) { 10 | if (i === index) { 11 | const row = model.data.rows[r]!; 12 | rows.push( 13 | `${row.cells 14 | .map((cell) => { 15 | const val = 16 | typeof cell.value === "string" 17 | ? escapeHtml(cell.value) 18 | : cell.value; 19 | return `${ 20 | cell.type !== CellType.Value ? `${val}` : val 21 | }`; 22 | }) 23 | .join("\n")}` 24 | ); 25 | index = model.data.indexes[++r]; 26 | } else { 27 | rows.push(``); 28 | } 29 | } 30 | return `${rows.join("\n")}
`; 31 | } 32 | -------------------------------------------------------------------------------- /packages/core/src/block/row.ts: -------------------------------------------------------------------------------- 1 | import type { Rows } from "../json-table.js"; 2 | 3 | export function shiftPositionsInPlace(columns: number[], offset: number): void { 4 | for (let i = 0; i < columns.length; i++) { 5 | columns[i]! += offset; 6 | } 7 | } 8 | 9 | export function scaleRowsVerticallyInPlace( 10 | { rows, indexes }: Rows, 11 | multiplier: number 12 | ): void { 13 | for (let i = 0; i < rows.length; i++) { 14 | indexes[i] = indexes[i]! * multiplier; 15 | const cells = rows[i]!.cells; 16 | for (let j = 0; j < cells.length; j++) { 17 | cells[j]!.height = cells[j]!.height * multiplier; 18 | } 19 | } 20 | } 21 | 22 | export function scaleRowsHorizontallyInPlace( 23 | { rows }: Rows, 24 | multiplier: number 25 | ): void { 26 | for (let i = 0; i < rows.length; i++) { 27 | const { cells, columns } = rows[i]!; 28 | for (let j = 0; j < cells.length; j++) { 29 | cells[j]!.width = cells[j]!.width * multiplier; 30 | columns[j] = columns[j]! * multiplier; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/properties-stabilizer.ts: -------------------------------------------------------------------------------- 1 | import { type Node, type Tree, insert, traverse } from "../lib/binary-tree.js"; 2 | 3 | interface KeyNode extends Node> { 4 | key: string; 5 | val: V; 6 | } 7 | 8 | export function makeObjectPropertiesStabilizer() { 9 | let index = 0; 10 | const order: Record> = {}; 11 | return (obj: Record) => { 12 | const entries = Object.entries(obj); 13 | let tree: Tree> = undefined; 14 | for (const [key, val] of entries) { 15 | const node = (order[key] ??= { 16 | key, 17 | val, 18 | value: index++, 19 | }); 20 | node.val = val; 21 | node.left = undefined; 22 | node.right = undefined; 23 | tree = insert(tree, node); 24 | } 25 | const keys: string[] = []; 26 | const values: V[] = []; 27 | traverse(tree, (node) => { 28 | keys.push(node.key); 29 | values.push(node.val); 30 | }); 31 | return [keys, values] as const; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Roman Krasilnikov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /apps/web/src/theme-picker.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 41 | -------------------------------------------------------------------------------- /apps/web/src/core/html.ts: -------------------------------------------------------------------------------- 1 | import type { Block } from '@json-table/core'; 2 | import { blockToHTML } from '@json-table/core/block-to-html'; 3 | 4 | import type { Entry } from '@/lib/entry'; 5 | 6 | export const HTML_TABLE_STYLES = `table, th, td {border: 1px solid black; border-collapse: collapse;} th, td {padding: 5px; text-align: left;} th:has(> b), td:has(> b) {text-align: center;}`; 7 | 8 | export const renderHTMLPage = ( 9 | title: string, 10 | content: string, 11 | style = "" 12 | ) => ` 13 | 14 | 15 | 16 | 17 | 18 | ${title} 19 | 22 | 23 | 24 | ${content} 25 | 26 | `; 27 | 28 | export function makeHTMLPageContent(tables: Entry[]) { 29 | return tables.length > 1 30 | ? tables 31 | .map(([title, table]) => `

${title}

${blockToHTML(table)}`) 32 | .join("
") 33 | : blockToHTML(tables[0]![1]); 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/src/download-table-page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 |
12 | 29 | 39 |
40 |
41 | -------------------------------------------------------------------------------- /packages/block-to-xlsx/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@json-table/block-to-xlsx", 3 | "version": "0.1.0", 4 | "description": "Library for rendering JSON Table to XLSX", 5 | "license": "MIT", 6 | "keywords": [ 7 | "json", 8 | "table", 9 | "xlsx" 10 | ], 11 | "type": "module", 12 | "files": [ 13 | "dist", 14 | "!dist/**/*.test.*", 15 | "!dist/**/*.spec.*" 16 | ], 17 | "publishConfig": { 18 | "provenance": true 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/x0k/json-table.git", 23 | "directory": "packages/block-to-xlsx" 24 | }, 25 | "bugs": "https://github.com/x0k/json-table/issues", 26 | "homepage": "https://x0k.github.io/json-table/", 27 | "scripts": { 28 | "build": "tsc", 29 | "test": "vitest --run" 30 | }, 31 | "exports": { 32 | ".": { 33 | "types": "./dist/block-to-xlsx.d.ts", 34 | "import": "./dist/block-to-xlsx.js" 35 | } 36 | }, 37 | "peerDependencies": { 38 | "exceljs": "^4.4.0", 39 | "@json-table/core": "workspace:^0.1.0 || ^0.2.0" 40 | }, 41 | "devDependencies": { 42 | "vitest": "catalog:", 43 | "@json-table/core": "workspace:^0.1.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/block-to-xlsx.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Block to XLSX 3 | --- 4 | 5 | import { Code, Card, CardGrid, LinkCard } from '@astrojs/starlight/components'; 6 | 7 | import Npm from '@/components/npm.astro'; 8 | 9 | Library for converting blocks to XLSX. 10 | 11 | ## Installation 12 | 13 | 14 | 15 | ## Usage 16 | 17 | ```typescript 18 | import { Workbook } from "exceljs"; 19 | import { max, sum } from "@json-table/core/lib/math"; 20 | import { makeBlockFactory } from "@json-table/core/json-to-table"; 21 | import { renderBlockOnWorksheet } from "@json-table/block-to-xlsx"; 22 | 23 | const block = makeBlockFactory({ 24 | cornerCellValue: "№", 25 | joinPrimitiveArrayValues: true, 26 | }); 27 | 28 | const wb = new Workbook(); 29 | 30 | /* Render table on the `Table` sheet */ 31 | renderBlockOnWorksheet(wb.addWorksheet("Table"), block(data), { 32 | columnWidth: (column, i, m, table) => { 33 | const counts = column.map((cell) => cell.count); 34 | return Math.max( 35 | Math.ceil( 36 | (counts.reduce(sum) / table.height + 37 | (counts.reduce(max) * column.length) / table.height) / 38 | 2 39 | ), 40 | 10 41 | ); 42 | }, 43 | }); 44 | 45 | ``` 46 | 47 | ## License 48 | 49 | MIT 50 | -------------------------------------------------------------------------------- /apps/web/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/web/public/company.json: -------------------------------------------------------------------------------- 1 | { 2 | "company": "TechCorp", 3 | "yearFounded": 2000, 4 | "founders": ["Alice", "Bob"], 5 | "headquarters": { 6 | "city": "Silicon Valley", 7 | "country": "USA" 8 | }, 9 | "departments": [ 10 | { 11 | "name": "Research", 12 | "employees": [ 13 | { 14 | "name": "Charlie", 15 | "position": "Research Scientist" 16 | }, 17 | { 18 | "name": "Diana", 19 | "position": "Research Analyst" 20 | } 21 | ] 22 | }, 23 | { 24 | "name": "Development", 25 | "employees": [ 26 | { 27 | "name": "Eva", 28 | "position": "Software Engineer" 29 | }, 30 | { 31 | "name": "Frank", 32 | "position": "UI/UX Designer" 33 | } 34 | ] 35 | }, 36 | { 37 | "name": "Marketing", 38 | "employees": [ 39 | { 40 | "name": "Grace", 41 | "position": "Marketing Manager" 42 | }, 43 | { 44 | "name": "Harry", 45 | "position": "Social Media Specialist" 46 | } 47 | ] 48 | } 49 | ], 50 | "projects": [ 51 | { 52 | "title": "Project A", 53 | "team": ["Alice", "Charlie", "Eva"], 54 | "progress": 75 55 | }, 56 | { 57 | "title": "Project B", 58 | "team": ["Bob", "Diana", "Frank"], 59 | "progress": 60 60 | }, 61 | { 62 | "title": "Project C", 63 | "team": ["Charlie", "Eva", "Grace"], 64 | "progress": 90 65 | } 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /apps/web/src/lib/actor/model.ts: -------------------------------------------------------------------------------- 1 | import type { Brand } from "../type.js"; 2 | import type { Result } from "../result.js"; 3 | import type { Context } from "../context.js"; 4 | 5 | export enum MessageType { 6 | Event = "event", 7 | Request = "request", 8 | Response = "response", 9 | } 10 | 11 | export interface AbstractMessage { 12 | type: T; 13 | } 14 | 15 | export interface EventMessage 16 | extends AbstractMessage { 17 | event: T; 18 | payload: P; 19 | } 20 | 21 | export type RequestId = Brand<"RequestId", number>; 22 | 23 | export interface RequestMessage 24 | extends AbstractMessage { 25 | id: RequestId; 26 | request: T; 27 | payload: P; 28 | } 29 | 30 | export interface ResponseMessage 31 | extends AbstractMessage { 32 | id: RequestId; 33 | result: R; 34 | } 35 | 36 | export interface Connection { 37 | onMessage: (ctx: Context, handler: (message: Incoming) => void) => void; 38 | send: (message: Outgoing) => void; 39 | } 40 | 41 | export type Handlers = Record any>; 42 | 43 | export type IncomingMessage> = { 44 | [K in keyof H]: 45 | | RequestMessage[0]> 46 | | EventMessage[0]>; 47 | }[keyof H]; 48 | 49 | export interface ErrorEventMessage extends EventMessage<"error", E> {} 50 | 51 | export type OutgoingMessage, E> = 52 | | { 53 | [K in keyof H]: ResponseMessage, E>>; 54 | }[keyof H] 55 | | ErrorEventMessage; 56 | -------------------------------------------------------------------------------- /apps/docs/src/astor.svelte.ts: -------------------------------------------------------------------------------- 1 | type DarkOrLight = "light" | "dark"; 2 | type Theme = "auto" | DarkOrLight; 3 | 4 | const STORAGE_KEY = "starlight-theme"; 5 | 6 | const parseTheme = (theme: unknown): Theme => 7 | theme === "auto" || theme === "dark" || theme === "light" ? theme : "auto"; 8 | 9 | const loadTheme = (): Theme => 10 | parseTheme( 11 | typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY) 12 | ); 13 | 14 | const getPreferredColorScheme = (): DarkOrLight => 15 | matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; 16 | 17 | export function theme() { 18 | let theme = $state(loadTheme()); 19 | 20 | $effect(() => { 21 | const starlightThemeSelect = document 22 | .getElementsByTagName("starlight-theme-select") 23 | .item(0); 24 | if (starlightThemeSelect === null) { 25 | return; 26 | } 27 | const themeSelect = starlightThemeSelect 28 | .getElementsByTagName("select") 29 | .item(0); 30 | if (themeSelect === null) { 31 | return; 32 | } 33 | const updateTheme = (e: Event) => { 34 | if (!(e.currentTarget instanceof HTMLSelectElement)) { 35 | return; 36 | } 37 | theme = parseTheme(e.currentTarget.value); 38 | }; 39 | themeSelect.addEventListener("change", updateTheme); 40 | return () => themeSelect.removeEventListener("change", updateTheme); 41 | }); 42 | 43 | const darkOrLight = $derived( 44 | theme === "auto" ? getPreferredColorScheme() : theme 45 | ); 46 | 47 | return { 48 | get theme() { 49 | return theme; 50 | }, 51 | get darkOrLight() { 52 | return darkOrLight; 53 | }, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /apps/docs/astro.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { fileURLToPath } from "node:url"; 3 | import { defineConfig } from "astro/config"; 4 | import starlight from "@astrojs/starlight"; 5 | import svelte from "@astrojs/svelte"; 6 | 7 | // https://astro.build/config 8 | export default defineConfig({ 9 | site: "https://x0k.github.io", 10 | base: "/json-table/docs/", 11 | trailingSlash: "always", 12 | i18n: { 13 | defaultLocale: "en", 14 | locales: ["en"], 15 | }, 16 | integrations: [ 17 | svelte(), 18 | starlight({ 19 | title: "JSON Table", 20 | social: [ 21 | { 22 | icon: "github", 23 | href: "https://github.com/x0k/json-table", 24 | label: "GitHub", 25 | }, 26 | ], 27 | head: [ 28 | { 29 | tag: "script", 30 | attrs: { 31 | "data-goatcounter": "https://json-table.counter.x0k.dev/count", 32 | async: true, 33 | src: "https://json-table.counter.x0k.dev/count.js", 34 | }, 35 | }, 36 | ], 37 | sidebar: [ 38 | { 39 | label: "Packages", 40 | items: [ 41 | { 42 | label: "@json-table/core", 43 | link: "/", 44 | }, 45 | { 46 | label: "@json-table/block-to-xlsx", 47 | link: "/block-to-xlsx/", 48 | }, 49 | ], 50 | }, 51 | ], 52 | components: { 53 | Head: "./src/components/custom-head.astro", 54 | Header: "./src/components/header-with-links.astro", 55 | }, 56 | }), 57 | ], 58 | vite: { 59 | resolve: { 60 | alias: { 61 | "@": fileURLToPath(new URL("./src", import.meta.url)), 62 | }, 63 | }, 64 | }, 65 | }); 66 | -------------------------------------------------------------------------------- /apps/web/src/core/actor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Actor, 3 | startRemote, 4 | WorkerConnection, 5 | type IncomingMessage, 6 | type OutgoingMessage, 7 | } from "@/lib/actor"; 8 | import { CanceledError, type Context } from "@/lib/context"; 9 | import { stringifyError } from "@/lib/error"; 10 | 11 | import type { TransformConfig } from "./model"; 12 | import { createTable } from "./create-table"; 13 | 14 | interface CreateTableOptions { 15 | data: string; 16 | config: TransformConfig; 17 | } 18 | 19 | interface Handlers { 20 | createTable(options: CreateTableOptions): Promise; 21 | } 22 | 23 | type Outgoing = OutgoingMessage; 24 | 25 | type Incoming = IncomingMessage; 26 | 27 | export function startTablesFactoryWorker(ctx: Context) { 28 | const connection = new WorkerConnection( 29 | self as unknown as Worker 30 | ); 31 | connection.start(ctx); 32 | const actor = new Actor( 33 | connection, 34 | { 35 | createTable({ data, config: transformConfig }) { 36 | return createTable(data, transformConfig); 37 | }, 38 | }, 39 | stringifyError 40 | ); 41 | actor.start(ctx); 42 | } 43 | 44 | interface WorkerConstructor { 45 | new (): Worker; 46 | } 47 | 48 | export function createRemoteTablesFactory( 49 | ctx: Context, 50 | Worker: WorkerConstructor 51 | ) { 52 | const worker = new Worker(); 53 | ctx.onCancel(() => { 54 | worker.terminate(); 55 | }); 56 | const connection = new WorkerConnection(worker); 57 | connection.start(ctx); 58 | return startRemote(ctx, console, connection, { 59 | error(err) { 60 | console.log(err instanceof CanceledError ? err.message : err); 61 | }, 62 | }); 63 | } 64 | 65 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@json-table/core", 3 | "version": "0.1.0", 4 | "description": "Library for creating tables from JSON data", 5 | "license": "MIT", 6 | "keywords": [ 7 | "json", 8 | "table", 9 | "ascii", 10 | "html-table" 11 | ], 12 | "type": "module", 13 | "files": [ 14 | "dist", 15 | "!dist/**/*.test.*", 16 | "!dist/**/*.spec.*" 17 | ], 18 | "publishConfig": { 19 | "provenance": true 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/x0k/json-table.git", 24 | "directory": "packages/core" 25 | }, 26 | "bugs": "https://github.com/x0k/json-table/issues", 27 | "homepage": "https://x0k.github.io/json-table/", 28 | "scripts": { 29 | "build": "tsc", 30 | "test": "vitest --run" 31 | }, 32 | "exports": { 33 | "./lib/*": { 34 | "types": "./dist/lib/*.d.ts", 35 | "import": "./dist/lib/*.js" 36 | }, 37 | ".": { 38 | "types": "./dist/json-table.d.ts", 39 | "import": "./dist/json-table.js" 40 | }, 41 | "./block": { 42 | "types": "./dist/block/index.d.ts", 43 | "import": "./dist/block/index.js" 44 | }, 45 | "./block-matrix": { 46 | "types": "./dist/block-matrix.d.ts", 47 | "import": "./dist/block-matrix.js" 48 | }, 49 | "./block-to-ascii": { 50 | "types": "./dist/block-to-ascii/index.d.ts", 51 | "import": "./dist/block-to-ascii/index.js" 52 | }, 53 | "./block-to-html": { 54 | "types": "./dist/block-to-html.d.ts", 55 | "import": "./dist/block-to-html.js" 56 | }, 57 | "./json-to-table": { 58 | "types": "./dist/json-to-table/index.d.ts", 59 | "import": "./dist/json-to-table/index.js" 60 | } 61 | }, 62 | "devDependencies": { 63 | "vitest": "catalog:" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @json-table/core 2 | 3 | Set of tools for converting JSON data to table (HTML, XLSX, ASCII). 4 | 5 | - [Web App](https://x0k.github.io/json-table/) 6 | - [Documentation](https://x0k.github.io/json-table/docs/) 7 | 8 | ## Install 9 | 10 | ```shell 11 | npm install @json-table/core 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```typescript 17 | import { makeBlockFactory } from "@json-table/core/json-to-table"; 18 | import { blockToASCII } from "@json-table/core/block-to-ascii"; 19 | 20 | const block = makeBlockFactory({ 21 | cornerCellValue: "№", 22 | joinPrimitiveArrayValues: true, 23 | }); 24 | const asciiTable = blockToASCII(block(data)); 25 | ``` 26 | 27 | Input data: 28 | 29 | ```json 30 | { 31 | "key": "val", 32 | "primitiveArr": [1, "two", false], 33 | "object": { 34 | "key1": "value1", 35 | "key2": 789, 36 | "key3": { 37 | "nestedKey": "nestedVal" 38 | } 39 | }, 40 | "nestedArray": [ 41 | { 42 | "name": "John", 43 | "age": 30, 44 | "isStud": false 45 | }, 46 | { 47 | "name": "Alice", 48 | "age": 25, 49 | "isStud": true 50 | } 51 | ] 52 | } 53 | ``` 54 | 55 | Output: 56 | 57 | ``` 58 | +-----+---------------+---------------------------+--------------------------+ 59 | | key | primitiveArr | object | nestedArray | 60 | +-----+---------------+--------+------+-----------+---+-------+-----+--------+ 61 | | | | key1 | key2 | key3 | № | name | age | isStud | 62 | | | +--------+------+-----------+---+-------+-----+--------+ 63 | | val | 1, two, false | | | nestedKey | 1 | John | 30 | false | 64 | | | | value1 | 789 +-----------+---+-------+-----+--------+ 65 | | | | | | nestedVal | 2 | Alice | 25 | true | 66 | +-----+---------------+--------+------+-----------+---+-------+-----+--------+ 67 | ``` 68 | 69 | ## License 70 | 71 | MIT 72 | -------------------------------------------------------------------------------- /packages/core/src/ascii-to-block/ascii-to-block.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | 3 | import { blockToASCII } from "../block-to-ascii"; 4 | 5 | import { getSimpleMySqlASCIITableSeparatorType } from "./model"; 6 | import { ASCIIToBlock } from "./ascii-to-block"; 7 | 8 | describe("fromASCIITable", () => { 9 | it("Should work with simple table", () => { 10 | const { value } = ASCIIToBlock( 11 | ` 12 | +---+ 13 | | a | 14 | +---+ 15 | `, 16 | getSimpleMySqlASCIITableSeparatorType 17 | ).data.rows[0].cells[0]; 18 | expect(value).toBe("a"); 19 | }); 20 | 21 | it("Should work with complex table", () => { 22 | const table = ASCIIToBlock( 23 | ` 24 | +--------------------------------------------+------------------------+ 25 | | Col1 | Col3 | 26 | +----------------------------------+---------+------------------------+ 27 | | Value 1 | Value 2 | 123 | 28 | +----------------------------------+---------+ with a tab or 4 spaces | 29 | | This is a row with only one cell | | | 30 | +----------------------------------+---------+------------------------+ 31 | `, 32 | getSimpleMySqlASCIITableSeparatorType 33 | ); 34 | const ascii = blockToASCII(table); 35 | expect(`\n${ascii}`).toBe(` 36 | +--------------------------------------------+------------------------+ 37 | | Col1 | Col3 | 38 | +----------------------------------+---------+------------------------+ 39 | | Value 1 | Value 2 | 123 | 40 | | | | with a tab or 4 spaces | 41 | +----------------------------------+---------+ | 42 | | This is a row with only one cell | | | 43 | +----------------------------------+---------+------------------------+`); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | concurrency: ${{ github.workflow }}-${{ github.ref }} 8 | 9 | permissions: 10 | id-token: write 11 | contents: write 12 | pull-requests: write 13 | pages: write 14 | 15 | jobs: 16 | version: 17 | timeout-minutes: 15 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout code repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v4 25 | 26 | - name: Setup node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 22 30 | cache: pnpm 31 | 32 | - name: Install dependencies 33 | run: pnpm install 34 | 35 | - name: Cache turbo build setup 36 | uses: actions/cache@v4 37 | with: 38 | path: .turbo 39 | key: ${{ runner.os }}-turbo-${{ github.sha }} 40 | restore-keys: | 41 | ${{ runner.os }}-turbo- 42 | 43 | - name: Build 44 | run: pnpm run ci:build 45 | 46 | - name: create and publish versions 47 | uses: changesets/action@v1 48 | with: 49 | version: pnpm ci:version 50 | publish: pnpm exec changeset publish 51 | commit: "[ci] release" 52 | title: "[ci] release" 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 56 | 57 | - name: Merge apps build outputs 58 | run: mv apps/docs/dist apps/web/dist/docs 59 | 60 | - name: Upload Pages Artifact 61 | uses: actions/upload-pages-artifact@v3 62 | with: 63 | path: "apps/web/dist/" 64 | 65 | deploy: 66 | needs: version 67 | runs-on: ubuntu-latest 68 | environment: 69 | name: github-pages 70 | url: ${{ steps.deployment.outputs.page_url }} 71 | steps: 72 | - name: Deploy to GitHub Pages 73 | id: deployment 74 | uses: actions/deploy-pages@v4 75 | -------------------------------------------------------------------------------- /apps/web/src/theme.svelte.ts: -------------------------------------------------------------------------------- 1 | import { MediaQuery } from "svelte/reactivity"; 2 | 3 | import { Theme, type DarkOrLight } from "@/lib/theme"; 4 | 5 | export interface ThemeManager { 6 | theme: Theme; 7 | readonly darkOrLight: DarkOrLight; 8 | readonly isDark: boolean; 9 | sync: () => void; 10 | } 11 | 12 | function createThemeManager( 13 | get: () => Theme | undefined, 14 | set: (theme: Theme) => void, 15 | sync: (manager: ThemeManager) => void 16 | ) { 17 | const preferredColorSchemeQuery = new MediaQuery( 18 | "(prefers-color-scheme: dark)" 19 | ); 20 | let theme = $state(get() ?? Theme.System); 21 | const darkOrLight = $derived( 22 | theme === Theme.System 23 | ? preferredColorSchemeQuery.current 24 | ? Theme.Dark 25 | : Theme.Light 26 | : theme 27 | ); 28 | const isDark = $derived(darkOrLight === Theme.Dark); 29 | const manager = { 30 | sync() { 31 | sync(manager); 32 | }, 33 | get theme() { 34 | return theme; 35 | }, 36 | set theme(v) { 37 | theme = v; 38 | set(v); 39 | sync(manager); 40 | }, 41 | get darkOrLight() { 42 | return darkOrLight; 43 | }, 44 | get isDark() { 45 | return isDark; 46 | }, 47 | set isDark(v) { 48 | manager.theme = v ? Theme.Dark : Theme.Light; 49 | }, 50 | } satisfies ThemeManager; 51 | return manager; 52 | } 53 | 54 | export function createDumbThemeManager(theme: Theme): ThemeManager { 55 | const isDark = theme === Theme.Dark; 56 | return { 57 | theme, 58 | isDark, 59 | darkOrLight: isDark ? Theme.Dark : Theme.Light, 60 | sync() {}, 61 | }; 62 | } 63 | 64 | export let themeManager: ThemeManager = createDumbThemeManager(Theme.Light); 65 | 66 | $effect.root(() => { 67 | themeManager = createThemeManager( 68 | () => (localStorage.getItem("theme") as Theme) || undefined, 69 | (theme) => localStorage.setItem("theme", theme), 70 | (m) => { 71 | document.documentElement.dataset.theme = m.isDark ? "dim" : "light"; 72 | } 73 | ); 74 | }); 75 | -------------------------------------------------------------------------------- /apps/web/src/main.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "svelte"; 2 | 3 | import { isValidUrl } from "./lib/url"; 4 | 5 | import MainPage from "./main-page.svelte"; 6 | import DownloadTablePage from "./download-table-page.svelte"; 7 | import HtmlTablePage from "./html-table-page.svelte"; 8 | import { OutputFormat, type TransformConfig, TransformPreset } from "./core"; 9 | import { compressor, appWorker } from "./init"; 10 | import { fetchAsText, type SharedData } from "./model"; 11 | import "./app.css"; 12 | 13 | const target = document.getElementById("app")!; 14 | 15 | function page() { 16 | let initialData = ""; 17 | let initialOptions: TransformConfig = { 18 | preset: TransformPreset.Default, 19 | transform: false, 20 | format: OutputFormat.HTML, 21 | paginate: false, 22 | }; 23 | const hash = location.hash.substring(1); 24 | if (hash !== "") { 25 | const { data, options, createOnOpen }: SharedData = JSON.parse( 26 | compressor.decompress(hash) 27 | ); 28 | if (data) { 29 | initialData = data; 30 | } 31 | if (options) { 32 | initialOptions = options; 33 | } 34 | if (createOnOpen) { 35 | const table = isValidUrl(initialData) 36 | ? fetchAsText(initialData).then((data) => 37 | appWorker.createTable({ data, config: initialOptions }) 38 | ) 39 | : appWorker.createTable({ 40 | data: initialData, 41 | config: initialOptions, 42 | }); 43 | switch (initialOptions.format) { 44 | case OutputFormat.XLSX: 45 | return mount(DownloadTablePage, { 46 | target, 47 | props: { 48 | title: "table.xlsx", 49 | content: table, 50 | }, 51 | }); 52 | default: 53 | return mount(HtmlTablePage, { 54 | target, 55 | props: { 56 | content: table, 57 | }, 58 | }); 59 | } 60 | } 61 | } 62 | return mount(MainPage, { 63 | target, 64 | props: { 65 | initialData, 66 | initialOptions, 67 | }, 68 | }); 69 | } 70 | 71 | export default page(); 72 | -------------------------------------------------------------------------------- /packages/core/src/json-table.ts: -------------------------------------------------------------------------------- 1 | import type { JSONPrimitive } from "./lib/json.js"; 2 | 3 | export interface Height { 4 | height: number; 5 | } 6 | 7 | export interface Width { 8 | width: number; 9 | } 10 | 11 | export interface Sized extends Height, Width {} 12 | 13 | export enum CellType { 14 | Header = "header", 15 | Index = "index", 16 | Value = "value", 17 | Corner = "corner", 18 | } 19 | 20 | export interface Cell extends Sized { 21 | value: V; 22 | type: CellType; 23 | } 24 | 25 | export interface Cells { 26 | cells: Cell[]; 27 | /** Absolute position in row for each cell */ 28 | columns: number[]; 29 | } 30 | 31 | export interface Rows { 32 | rows: Cells[]; 33 | /** Absolute position in column for each row */ 34 | indexes: number[]; 35 | } 36 | 37 | export interface Block extends Sized { 38 | data: Rows; 39 | } 40 | 41 | export interface Table { 42 | head: Block | null; 43 | indexes: Block | null; 44 | body: Block; 45 | } 46 | 47 | export type ProportionalResizeGuard = ( 48 | lcmValue: number, 49 | maxValue: number 50 | ) => boolean; 51 | 52 | export type RowsScaler = ( 53 | rows: Rows, 54 | multiplier: number, 55 | ) => void; 56 | 57 | export type BlockTransformInPlace = (block: Block) => void; 58 | 59 | export type BlockTransform = (block: Block) => Block; 60 | 61 | export type BlockCompositor = (blocks: Block[]) => Block; 62 | 63 | export interface ComposedTable extends Table { 64 | baked: Block[]; 65 | } 66 | 67 | export type TableCompositor = (tables: Table[]) => ComposedTable; 68 | 69 | export type TableComponent = "head" | "indexes"; 70 | export type BlockSizeAspect = "height" | "width"; 71 | 72 | export const BLOCK_SIZE_ASPECT_OPPOSITES: Record< 73 | BlockSizeAspect, 74 | BlockSizeAspect 75 | > = { 76 | height: "width", 77 | width: "height", 78 | }; 79 | 80 | export const TABLE_COMPONENT_SIZE_ASPECTS: Record< 81 | TableComponent, 82 | BlockSizeAspect 83 | > = { 84 | head: "height", 85 | indexes: "width", 86 | }; 87 | 88 | export const TABLE_COMPONENT_OPPOSITES: Record = 89 | { 90 | head: "indexes", 91 | indexes: "head", 92 | }; 93 | -------------------------------------------------------------------------------- /apps/web/src/lib/actor/actor.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from '../context.js'; 2 | import { err, ok, type Result } from "../result.js"; 3 | import { neverError } from "../error.js"; 4 | 5 | import { 6 | MessageType, 7 | type Connection, 8 | type EventMessage, 9 | type Handlers, 10 | type IncomingMessage, 11 | type OutgoingMessage, 12 | type RequestMessage, 13 | type ResponseMessage, 14 | } from "./model.js"; 15 | 16 | export interface ActorConfig, E> { 17 | connection: Connection, OutgoingMessage>; 18 | handlers: H; 19 | caseError: (error: any) => E; 20 | } 21 | 22 | export class Actor, E> { 23 | constructor( 24 | protected readonly connection: Connection< 25 | IncomingMessage, 26 | OutgoingMessage 27 | >, 28 | protected readonly handlers: H, 29 | protected readonly caseError: (error: any) => E 30 | ) {} 31 | 32 | start(ctx: Context) { 33 | this.connection.onMessage(ctx, this.handleMessage.bind(this)); 34 | } 35 | 36 | private async handleMessage(msg: IncomingMessage) { 37 | switch (msg.type) { 38 | case MessageType.Request: { 39 | const result = await this.handleRequest(msg); 40 | this.connection.send(result); 41 | return; 42 | } 43 | case MessageType.Event: { 44 | await this.handleEvent(msg); 45 | return; 46 | } 47 | default: 48 | throw neverError(msg, "Unknown message type"); 49 | } 50 | } 51 | 52 | private async handleEvent( 53 | event: EventMessage[0]> 54 | ) { 55 | try { 56 | await this.handlers[event.event]!(event.payload); 57 | } catch (error) { 58 | this.connection.send({ 59 | type: MessageType.Event, 60 | event: "error", 61 | payload: this.caseError(error), 62 | }); 63 | } 64 | } 65 | 66 | private async handleRequest( 67 | request: RequestMessage[0]> 68 | ): Promise, E>>> { 69 | try { 70 | const result = await this.handlers[request.request]!(request.payload); 71 | return { 72 | type: MessageType.Response, 73 | id: request.id, 74 | result: ok(result), 75 | }; 76 | } catch (error) { 77 | return { 78 | type: MessageType.Response, 79 | id: request.id, 80 | result: err(this.caseError(error)), 81 | }; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON Table 2 | 3 | Set of tools for converting JSON data into tables (HTML, XLSX, ASCII). 4 | 5 | - [Web App](https://x0k.github.io/json-table/) 6 | - [Documentation](https://x0k.github.io/json-table/docs/) 7 | 8 | ## Install 9 | 10 | ```shell 11 | npm install @json-table/core 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```typescript 17 | import { makeBlockFactory } from "@json-table/core/json-to-table"; 18 | import { blockToASCII } from "@json-table/core/block-to-ascii"; 19 | import { blockToHTML } from '@json-table/core/block-to-html'; 20 | 21 | const createBlock = makeBlockFactory({ 22 | cornerCellValue: "№", 23 | joinPrimitiveArrayValues: true, 24 | }); 25 | 26 | const block = createBlock(data) 27 | 28 | const asciiTable = blockToASCII(block); 29 | 30 | /* Or */ 31 | 32 | const htmlTable = blockToHTML(block); 33 | 34 | ``` 35 | 36 | > [!TIP] 37 | > See [block-to-html](https://github.com/x0k/json-table/blob/main/packages/core/src/block-to-html.ts) source code to create your own renderer 38 | 39 | Input data: 40 | 41 | ```json 42 | { 43 | "key": "val", 44 | "primitiveArr": [1, "two", false], 45 | "object": { 46 | "key1": "value1", 47 | "key2": 789, 48 | "key3": { 49 | "nestedKey": "nestedVal" 50 | } 51 | }, 52 | "nestedArray": [ 53 | { 54 | "name": "John", 55 | "age": 30, 56 | "isStud": false 57 | }, 58 | { 59 | "name": "Alice", 60 | "age": 25, 61 | "isStud": true 62 | } 63 | ] 64 | } 65 | 66 | ``` 67 | 68 | Output: 69 | 70 | ``` 71 | +-----+---------------+---------------------------+--------------------------+ 72 | | key | primitiveArr | object | nestedArray | 73 | +-----+---------------+--------+------+-----------+---+-------+-----+--------+ 74 | | | | key1 | key2 | key3 | № | name | age | isStud | 75 | | | +--------+------+-----------+---+-------+-----+--------+ 76 | | val | 1, two, false | | | nestedKey | 1 | John | 30 | false | 77 | | | | value1 | 789 +-----------+---+-------+-----+--------+ 78 | | | | | | nestedVal | 2 | Alice | 25 | true | 79 | +-----+---------------+--------+------+-----------+---+-------+-----+--------+ 80 | ``` 81 | 82 | ## License 83 | 84 | MIT 85 | 86 | ## See also 87 | 88 | - Use this app to render JSON responses as tables in your browser by [WebMaid](https://github.com/x0k/web-maid/tree/main/examples/json-to-table) 89 | - Simple build automation tool [mk](https://github.com/x0k/mk) 90 | -------------------------------------------------------------------------------- /apps/web/src/lib/context.ts: -------------------------------------------------------------------------------- 1 | import { noop } from "./function.js"; 2 | 3 | export interface Context { 4 | readonly signal: AbortSignal; 5 | onCancel(action: () => void): Disposable; 6 | } 7 | 8 | function createContextFromSignal(signal: AbortSignal): Context { 9 | return { 10 | signal, 11 | onCancel(action) { 12 | if (signal.aborted) { 13 | action(); 14 | return { [Symbol.dispose]: noop }; 15 | } 16 | const dispose = () => signal.removeEventListener("abort", handler); 17 | const handler = () => { 18 | dispose(); 19 | action(); 20 | }; 21 | signal.addEventListener("abort", handler); 22 | return { [Symbol.dispose]: dispose }; 23 | }, 24 | }; 25 | } 26 | 27 | export function createContext(): Context { 28 | const ctrl = new AbortController(); 29 | return createContextFromSignal(ctrl.signal); 30 | } 31 | 32 | export function withCancel(ctx: Context): [Context, () => void] { 33 | const ctrl = new AbortController(); 34 | const signal = AbortSignal.any([ctx.signal, ctrl.signal]); 35 | return [createContextFromSignal(signal), ctrl.abort.bind(ctrl)]; 36 | } 37 | 38 | export function withTimeout(ctx: Context, timeoutInMs: number): Context { 39 | const signal = AbortSignal.any([ 40 | ctx.signal, 41 | AbortSignal.timeout(timeoutInMs), 42 | ]); 43 | return createContextFromSignal(signal); 44 | } 45 | 46 | export class CanceledError extends Error { 47 | constructor() { 48 | super("Context is canceled"); 49 | } 50 | } 51 | 52 | export function inContext(ctx: Context, promise: Promise): Promise { 53 | return new Promise((resolve, reject) => { 54 | if (ctx.signal.aborted) { 55 | reject(new CanceledError()); 56 | return; 57 | } 58 | const cancel = () => { 59 | reject(new CanceledError()); 60 | }; 61 | ctx.signal.addEventListener("abort", cancel); 62 | promise.then(resolve, reject).finally(() => { 63 | ctx.signal.removeEventListener("abort", cancel); 64 | }); 65 | }); 66 | } 67 | 68 | export interface RecoverableContext extends Disposable { 69 | ref: Context; 70 | cancel: () => void; 71 | } 72 | 73 | export function createRecoverableContext( 74 | contextFactory: () => [Context, () => void] 75 | ): RecoverableContext { 76 | let [ref, cancel] = contextFactory(); 77 | const disposable = ref.onCancel(function handleCancel() { 78 | [ref, cancel] = contextFactory(); 79 | recoverable.ref = ref; 80 | recoverable.cancel = cancel; 81 | recoverable[Symbol.dispose] = ref.onCancel(handleCancel)[Symbol.dispose]; 82 | }); 83 | const recoverable: RecoverableContext = { 84 | ref, 85 | cancel, 86 | [Symbol.dispose]: disposable[Symbol.dispose], 87 | }; 88 | return recoverable; 89 | } 90 | -------------------------------------------------------------------------------- /packages/core/src/block-matrix.ts: -------------------------------------------------------------------------------- 1 | import { array } from "./lib/array.js"; 2 | import { type Matrix, matrix } from "./lib/matrix.js"; 3 | 4 | import { compressRawRowsInPlaceAndMakeIndexes } from './block/index.js'; 5 | 6 | import { type Block, type Cells, CellType, type Cell } from "./json-table.js"; 7 | 8 | const UNDEFINED_CELL = Symbol("undefined cell"); 9 | 10 | export function createMatrix( 11 | { height, width, data }: Block, 12 | getValue: ( 13 | cell: Cell, 14 | rowIndex: number, 15 | colIndex: number, 16 | indexInRow: number 17 | ) => R 18 | ): Matrix { 19 | const m = matrix( 20 | height, 21 | width, 22 | () => UNDEFINED_CELL 23 | ); 24 | for (let i = 0; i < data.rows.length; i++) { 25 | const row = data.rows[i]!; 26 | const index = data.indexes[i]!; 27 | for (let j = 0; j < row.cells.length; j++) { 28 | const cell = row.cells[j]!; 29 | const col = row.columns[j]!; 30 | const { height: cellHeight, width: cellWidth } = cell; 31 | const value = getValue(cell, index, col, j); 32 | for (let h = index; h < index + cellHeight && h < height; h++) { 33 | for (let w = col; w < col + cellWidth && w < width; w++) { 34 | m[h]![w] = value; 35 | } 36 | } 37 | } 38 | } 39 | return m as Matrix; 40 | } 41 | 42 | /** Uses reference equality to define cell boundaries */ 43 | export function fromMatrix( 44 | matrix: Matrix, 45 | getCellType: (value: T, rowIndex: number, colIndex: number) => CellType, 46 | getCellValue: (value: T, rowIndex: number, colIndex: number) => R 47 | ): Block { 48 | const height = matrix.length; 49 | const width = matrix[0]!.length; 50 | const cells = new Set(); 51 | const rows = array( 52 | height, 53 | (): Cells => ({ 54 | cells: [], 55 | columns: [], 56 | }) 57 | ); 58 | for (let i = 0; i < height; i++) { 59 | let j = 0; 60 | while (j < width) { 61 | const cell = matrix[i]![j]!; 62 | if (cells.has(cell)) { 63 | j++; 64 | continue; 65 | } 66 | let h = 1; 67 | while (i + h < height && matrix[i + h]![j] === cell) { 68 | h++; 69 | } 70 | const wStart = j++; 71 | while (j < width && matrix[i]![j] === cell) { 72 | j++; 73 | } 74 | rows[i]!.cells.push({ 75 | height: h, 76 | width: j - wStart, 77 | type: getCellType(cell, i, wStart), 78 | value: getCellValue(cell, i, wStart), 79 | }); 80 | rows[i]!.columns.push(wStart); 81 | cells.add(cell); 82 | } 83 | } 84 | const indexes = compressRawRowsInPlaceAndMakeIndexes(rows); 85 | return { 86 | height, 87 | width, 88 | data: { rows, indexes }, 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /packages/core/src/ascii-to-block/ascii-to-block.ts: -------------------------------------------------------------------------------- 1 | import { array } from "../lib/array.js"; 2 | import { matrix } from "../lib/matrix.js"; 3 | 4 | import { CellType } from "../json-table.js"; 5 | import { fromMatrix } from '../block-matrix.js'; 6 | 7 | import { 8 | getContentOfRawCell, 9 | getMaxLineLength, 10 | omitEmptyLines, 11 | type RawCell, 12 | SeparatorType, 13 | } from "./model.js"; 14 | 15 | export function ASCIIToBlock( 16 | ascii: string, 17 | getSeparatorType: ( 18 | char: string, 19 | colIndex: number, 20 | row: string, 21 | rowIndex: number, 22 | rows: string[] 23 | ) => SeparatorType 24 | ) { 25 | const rows = ascii.split("\n"); 26 | omitEmptyLines(rows); 27 | const originalHeight = rows.length; 28 | const originalWidth = getMaxLineLength(rows); 29 | const regions = matrix( 30 | originalHeight + 1, 31 | originalWidth + 1, 32 | () => null 33 | ); 34 | const xShift = array(originalWidth + 1, () => 0); 35 | const yShift = array(originalHeight + 1, () => 0); 36 | for (let i = 0; i < originalHeight; i++) { 37 | const row = rows[i]!; 38 | for (let j = 0; j < originalWidth; j++) { 39 | const char = row[j]!; 40 | const separatorType = getSeparatorType(char, j, row, i, rows); 41 | xShift[j + 1] = Math.max(xShift[j + 1]!, separatorType & 1); 42 | yShift[i + 1] = Math.max(yShift[i + 1]!, (separatorType & 2) >> 1); 43 | if (separatorType > 0) { 44 | continue; 45 | } 46 | const region = regions[i + 1]![j] || 47 | regions[i]![j + 1] || { 48 | x1: j, 49 | y1: i, 50 | x2: j, 51 | y2: i, 52 | }; 53 | region.x2 = j; 54 | region.y2 = i; 55 | regions[i + 1]![j + 1] = region; 56 | } 57 | } 58 | // Accumulate 59 | for (let i = 1; i <= originalWidth; i++) { 60 | xShift[i]! += xShift[i - 1]!; 61 | } 62 | for (let i = 1; i <= originalHeight; i++) { 63 | yShift[i]! += yShift[i - 1]!; 64 | } 65 | const width = originalWidth - xShift[originalWidth]!; 66 | const height = originalHeight - yShift[originalHeight]!; 67 | const cleanMatrix = matrix(height, width, () => null); 68 | for (let i = 1; i <= originalHeight; i++) { 69 | for (let j = 1; j <= originalWidth; j++) { 70 | const region = regions[i]![j]!; 71 | if (region === null) { 72 | continue; 73 | } 74 | cleanMatrix[i - yShift[i]! - 1]![j - xShift[j]! - 1] = region; 75 | } 76 | } 77 | return fromMatrix( 78 | cleanMatrix, 79 | (_, rowIndex) => (rowIndex === 0 ? CellType.Header : CellType.Value), 80 | (cell) => { 81 | if (cell === null) { 82 | throw new Error("Invalid table"); 83 | } 84 | return getContentOfRawCell(rows, cell); 85 | } 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: JSON Table 3 | # description: Main page 4 | # template: splash 5 | hero: 6 | tagline: Set of tools for converting JSON data into tables (HTML, XLSX, ASCII). 7 | actions: 8 | # - text: Get Started 9 | # link: guides/quickstart/ 10 | # icon: right-arrow 11 | - text: Web App 12 | link: https://x0k.github.io/json-table/ 13 | icon: external 14 | # variant: minimal 15 | - text: View on GitHub 16 | link: https://github.com/x0k/json-table/ 17 | icon: external 18 | variant: minimal 19 | --- 20 | 21 | import { Code, Card, CardGrid, LinkCard } from '@astrojs/starlight/components'; 22 | 23 | import Npm from '@/components/npm.astro'; 24 | 25 | ## Installation 26 | 27 | 28 | 29 | ## Usage 30 | 31 | ```typescript 32 | import { makeBlockFactory } from "@json-table/core/json-to-table"; 33 | import { blockToASCII } from "@json-table/core/block-to-ascii"; 34 | import { blockToHTML } from '@json-table/core/block-to-html'; 35 | 36 | const createBlock = makeBlockFactory({ 37 | cornerCellValue: "№", 38 | joinPrimitiveArrayValues: true, 39 | }); 40 | 41 | const block = createBlock(data) 42 | 43 | const asciiTable = blockToASCII(block); 44 | 45 | /* Or */ 46 | 47 | const htmlTable = blockToHTML(block); 48 | ``` 49 | 50 | :::tip 51 | See [block-to-html](https://github.com/x0k/json-table/blob/main/packages/core/src/block-to-html.ts) source code to create your own renderer 52 | ::: 53 | 54 | Input data: 55 | 56 | ```json 57 | { 58 | "key": "val", 59 | "primitiveArr": [1, "two", false], 60 | "object": { 61 | "key1": "value1", 62 | "key2": 789, 63 | "key3": { 64 | "nestedKey": "nestedVal" 65 | } 66 | }, 67 | "nestedArray": [ 68 | { 69 | "name": "John", 70 | "age": 30, 71 | "isStud": false 72 | }, 73 | { 74 | "name": "Alice", 75 | "age": 25, 76 | "isStud": true 77 | } 78 | ] 79 | } 80 | 81 | ``` 82 | 83 | Output: 84 | 85 | ``` 86 | +-----+---------------+---------------------------+--------------------------+ 87 | | key | primitiveArr | object | nestedArray | 88 | +-----+---------------+--------+------+-----------+---+-------+-----+--------+ 89 | | | | key1 | key2 | key3 | № | name | age | isStud | 90 | | | +--------+------+-----------+---+-------+-----+--------+ 91 | | val | 1, two, false | | | nestedKey | 1 | John | 30 | false | 92 | | | | value1 | 789 +-----------+---+-------+-----+--------+ 93 | | | | | | nestedVal | 2 | Alice | 25 | true | 94 | +-----+---------------+--------+------+-----------+---+-------+-----+--------+ 95 | ``` 96 | 97 | ## License 98 | 99 | MIT 100 | -------------------------------------------------------------------------------- /packages/core/src/ascii-to-block/model.ts: -------------------------------------------------------------------------------- 1 | import type { Matrix } from "../lib/matrix.js"; 2 | 3 | export enum SeparatorType { 4 | None = 0, 5 | Horizontal = 1, 6 | Vertical = 2, 7 | Both = 3, 8 | } 9 | 10 | export type RawCell = { 11 | x1: number; 12 | y1: number; 13 | y2: number; 14 | x2: number; 15 | } | null; 16 | 17 | /** Does not escape for separators */ 18 | export function getSimpleMySqlASCIITableSeparatorType( 19 | char: string, 20 | colIndex: number, 21 | row: string, 22 | rowIndex: number, 23 | rows: string[] 24 | ) { 25 | switch (char) { 26 | case "+": { 27 | return SeparatorType.Both; 28 | // const x = colIndex === row.length - 1 || row[colIndex + 1] === "-" ? SeparatorType.Horizontal : 0; 29 | // const y = rowIndex === rows.length - 1 || rows[rowIndex + 1][colIndex] === "|" ? SeparatorType.Vertical : 0; 30 | // return x | y; 31 | } 32 | case "|": 33 | return SeparatorType.Horizontal; 34 | // TODO: Check that line ends is a `both` separators 35 | case "-": { 36 | if (row.length < 2) { 37 | return SeparatorType.Vertical; 38 | } 39 | const prevOrNextInRowToken = 40 | colIndex > 0 ? row[colIndex - 1] : row[colIndex + 1]; 41 | return prevOrNextInRowToken === "-" || prevOrNextInRowToken === "+" 42 | ? SeparatorType.Vertical 43 | : SeparatorType.None; 44 | } 45 | default: 46 | return SeparatorType.None; 47 | } 48 | } 49 | 50 | export function getContentOfRawCell( 51 | rows: string[], 52 | { x1, y1, x2, y2 }: NonNullable 53 | ) { 54 | return rows 55 | .slice(y1, y2 + 1) 56 | .map((row) => row.substring(x1, x2 + 1).trim()) 57 | .join("\n"); 58 | } 59 | 60 | export function printRawMatrix(matrix: Matrix) { 61 | const cells = new Set(); 62 | const cellsIds = new Map(); 63 | let lastId = 1; 64 | for (let i = 0; i < matrix.length; i++) { 65 | for (let j = 0; j < matrix[i]!.length; j++) { 66 | const cell = matrix[i]![j]!; 67 | if (cell !== null && !cells.has(cell)) { 68 | cells.add(cell); 69 | cellsIds.set(cell, String(lastId++)); 70 | } 71 | } 72 | } 73 | const pad = lastId.toString().length; 74 | for (let i = 0; i < matrix.length; i++) { 75 | const row = matrix[i]!; 76 | let str = ""; 77 | for (let j = 0; j < row.length; j++) { 78 | const cell = row[j]; 79 | if (cell) { 80 | str += cellsIds.get(cell)!.padStart(pad, " "); 81 | } else { 82 | str += "+".repeat(pad); 83 | } 84 | } 85 | console.log(str); 86 | } 87 | } 88 | 89 | export function omitEmptyLines(rows: string[]) { 90 | let l = 0; 91 | while (l < rows.length) { 92 | if (rows[l]!.trim() === "") { 93 | rows.splice(l, 1); 94 | } else { 95 | l++; 96 | } 97 | } 98 | } 99 | 100 | export function getMaxLineLength(rows: string[]) { 101 | let max = 0; 102 | for (let i = 0; i < rows.length; i++) { 103 | max = Math.max(max, rows[i]!.length); 104 | } 105 | return max; 106 | } 107 | -------------------------------------------------------------------------------- /apps/web/src/core/model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | horizontalMirror, 3 | transpose, 4 | verticalMirror, 5 | } from "@json-table/core/lib/matrix"; 6 | import type { JSONPrimitive } from "@json-table/core/lib/json"; 7 | import type { Block } from "@json-table/core"; 8 | import { createMatrix, fromMatrix } from "@json-table/core/block-matrix"; 9 | import { ASCIITableFormat } from "@json-table/core/block-to-ascii"; 10 | import type { TableFactoryOptions } from "@json-table/core/json-to-table"; 11 | 12 | export enum TransformPreset { 13 | Default = "Default", 14 | Manual = "Manual", 15 | } 16 | 17 | export enum OutputFormat { 18 | HTML = "HTML", 19 | XLSX = "XLSX", 20 | ASCII = "ASCII", 21 | } 22 | export type TransformConfig = { 23 | paginate: boolean; 24 | } & ( 25 | | { 26 | format: OutputFormat.ASCII; 27 | asciiFormat: ASCIITableFormat; 28 | } 29 | | { 30 | format: OutputFormat.HTML; 31 | } 32 | | { 33 | format: OutputFormat.XLSX; 34 | } 35 | ) & 36 | ( 37 | | { preset: TransformPreset.Default } 38 | | ({ 39 | preset: TransformPreset.Manual; 40 | } & TableFactoryOptions) 41 | ) & 42 | ( 43 | | { transform: false } 44 | | { 45 | transform: true; 46 | horizontalReflect: boolean; 47 | verticalReflect: boolean; 48 | transpose: boolean; 49 | } 50 | ); 51 | 52 | export function extractTableFactoryOptions( 53 | config: TransformConfig 54 | ): TableFactoryOptions { 55 | switch (config.preset) { 56 | case TransformPreset.Default: 57 | return { 58 | cornerCellValue: "№", 59 | joinPrimitiveArrayValues: true, 60 | combineArraysOfObjects: false, 61 | proportionalSizeAdjustmentThreshold: 1, 62 | collapseIndexes: true, 63 | stabilizeOrderOfPropertiesInArraysOfObjects: true, 64 | }; 65 | case TransformPreset.Manual: { 66 | const { 67 | collapseIndexes, 68 | joinPrimitiveArrayValues, 69 | combineArraysOfObjects, 70 | stabilizeOrderOfPropertiesInArraysOfObjects, 71 | proportionalSizeAdjustmentThreshold, 72 | cornerCellValue, 73 | } = config; 74 | return { 75 | collapseIndexes, 76 | joinPrimitiveArrayValues, 77 | combineArraysOfObjects, 78 | stabilizeOrderOfPropertiesInArraysOfObjects, 79 | proportionalSizeAdjustmentThreshold, 80 | cornerCellValue: cornerCellValue ?? "", 81 | }; 82 | } 83 | default: { 84 | const n: never = config; 85 | throw new Error(`Unexpected preset "${JSON.stringify(n)}"`); 86 | } 87 | } 88 | } 89 | 90 | export function makeTransformApplicator(config: TransformConfig) { 91 | return (block: Block) => { 92 | if (!config.transform) { 93 | return block; 94 | } 95 | let matrix = createMatrix(block, ({ type, value }) => ({ type, value })); 96 | if (config.horizontalReflect) { 97 | matrix = horizontalMirror(matrix); 98 | } 99 | if (config.verticalReflect) { 100 | matrix = verticalMirror(matrix); 101 | } 102 | if (config.transpose) { 103 | matrix = transpose(matrix); 104 | } 105 | return fromMatrix( 106 | matrix, 107 | ({ type }) => type, 108 | ({ value }) => value 109 | ); 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /apps/web/src/core/create-table.ts: -------------------------------------------------------------------------------- 1 | import { escapeHtml } from "@json-table/core/lib/html"; 2 | import { type JSONValue, isJsonPrimitive } from "@json-table/core/lib/json"; 3 | import { max, sum } from "@json-table/core/lib/math"; 4 | import type { Block } from "@json-table/core"; 5 | import { blockToASCII } from "@json-table/core/block-to-ascii"; 6 | import { makeBlockFactory } from "@json-table/core/json-to-table"; 7 | 8 | import { type Entry, transformValue } from "@/lib/entry"; 9 | import { createFileURL, createXLSBlob } from "@/lib/file"; 10 | import { JSONParseStatus, jsonTryParse } from "@/lib/json-parser"; 11 | 12 | import { renderHTMLPage, HTML_TABLE_STYLES, makeHTMLPageContent } from "./html"; 13 | import { makeWorkBook } from "./xlsx"; 14 | 15 | import { 16 | OutputFormat, 17 | type TransformConfig, 18 | extractTableFactoryOptions, 19 | makeTransformApplicator, 20 | } from "./model"; 21 | 22 | function parseTableData(data: string): JSONValue { 23 | const dataParseResult = jsonTryParse(data); 24 | return dataParseResult.status === JSONParseStatus.Ok 25 | ? dataParseResult.data 26 | : { 27 | Error: `An error occurred while trying to recognize the data:\n"${dataParseResult.error}"`, 28 | }; 29 | } 30 | 31 | export async function createTable( 32 | data: string, 33 | transformConfig: TransformConfig 34 | ) { 35 | const options = extractTableFactoryOptions(transformConfig); 36 | const makeBlock = makeBlockFactory(options); 37 | const transformApplicator = makeTransformApplicator(transformConfig); 38 | const tableData = parseTableData(data); 39 | const pagesData: Entry[] = 40 | isJsonPrimitive(tableData) || !transformConfig.paginate 41 | ? [["Report", tableData] as Entry] 42 | : Array.isArray(tableData) 43 | ? tableData.map((item, i) => [String(i + 1), item] as Entry) 44 | : Object.keys(tableData).map( 45 | (key) => [key, tableData[key]] as Entry 46 | ); 47 | const pagesTables = pagesData 48 | .map(transformValue(makeBlock)) 49 | .map(transformValue(transformApplicator)); 50 | switch (transformConfig.format) { 51 | case OutputFormat.HTML: { 52 | return renderHTMLPage( 53 | "Table", 54 | makeHTMLPageContent(pagesTables), 55 | HTML_TABLE_STYLES 56 | ); 57 | } 58 | case OutputFormat.ASCII: { 59 | const renderTable = (t: Block) => 60 | `
${escapeHtml(
61 |           blockToASCII(t, { format: transformConfig.asciiFormat })
62 |         )}
`; 63 | return renderHTMLPage( 64 | "Table", 65 | pagesTables.length > 1 66 | ? pagesTables 67 | .map(([title, table]) => `

${title}

${renderTable(table)}`) 68 | .join("
") 69 | : renderTable(pagesTables[0]![1]) 70 | ); 71 | } 72 | case OutputFormat.XLSX: 73 | return makeWorkBook(pagesTables, { 74 | columnWidth: (column, i, m, table) => { 75 | const counts = column.map((cell) => cell.count); 76 | return Math.max( 77 | Math.ceil( 78 | (counts.reduce(sum) / table.height + 79 | (counts.reduce(max) * column.length) / table.height) / 80 | 2 81 | ), 82 | 10 83 | ); 84 | }, 85 | }) 86 | .xlsx.writeBuffer() 87 | .then(createXLSBlob) 88 | .then(createFileURL); 89 | default: 90 | throw new Error(`Unexpected output format`); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /apps/web/src/lib/actor/remote.ts: -------------------------------------------------------------------------------- 1 | import { CanceledError, type Context } from "../context.js"; 2 | import type { Logger } from "../logger.js"; 3 | import { neverError } from "../error.js"; 4 | import { isOk } from "../result.js"; 5 | 6 | import { 7 | MessageType, 8 | type Connection, 9 | type ErrorEventMessage, 10 | type EventMessage, 11 | type Handlers, 12 | type IncomingMessage, 13 | type OutgoingMessage, 14 | type RequestId, 15 | } from "./model.js"; 16 | 17 | interface DeferredPromise { 18 | resolve: (value: T) => void; 19 | reject: (error: E) => void; 20 | } 21 | 22 | export function startRemote< 23 | H extends Handlers, 24 | E, 25 | Event extends EventMessage 26 | >( 27 | ctx: Context, 28 | log: Logger, 29 | connection: Connection | Event, IncomingMessage>, 30 | eventHandlers: { 31 | [K in Event["event"]]: ( 32 | e: Extract>["payload"] 33 | ) => void; 34 | } & { 35 | [K in ErrorEventMessage["event"]]: ( 36 | e: ErrorEventMessage["payload"] 37 | ) => void; 38 | } 39 | ) { 40 | let lastId = 0; 41 | const promises = new Map< 42 | RequestId, 43 | DeferredPromise< 44 | { [K in keyof H]: ReturnType }[keyof H], 45 | E | CanceledError 46 | > 47 | >(); 48 | ctx.onCancel(() => { 49 | for (const [, p] of promises) { 50 | p.reject(new CanceledError()); 51 | } 52 | promises.clear(); 53 | }); 54 | connection.onMessage(ctx, (msg) => { 55 | switch (msg.type) { 56 | case MessageType.Response: { 57 | const { id, result } = msg; 58 | const deferred = promises.get(id); 59 | if (!deferred) { 60 | log.error(`Received response for unknown request ${id}`); 61 | return; 62 | } 63 | promises.delete(id); 64 | if (isOk(result)) { 65 | deferred.resolve(result.value); 66 | } else { 67 | deferred.reject(result.error); 68 | } 69 | return; 70 | } 71 | case MessageType.Event: { 72 | // @ts-expect-error ts problem 73 | const handler = eventHandlers[msg.event]; 74 | if (!handler) { 75 | log.error(`Received unknown event ${msg.event}`); 76 | return; 77 | } 78 | handler(msg.payload); 79 | return; 80 | } 81 | default: 82 | throw neverError(msg, "Unknown message type"); 83 | } 84 | }); 85 | return new Proxy( 86 | {}, 87 | { 88 | get(_, prop) { 89 | const request = prop as keyof H; 90 | return (arg: Parameters[0]) => { 91 | const id = lastId++ as RequestId; 92 | const promise = new Promise>( 93 | (resolve, reject) => { 94 | promises.set(id, { resolve, reject }); 95 | } 96 | ); 97 | connection.send({ 98 | id: id, 99 | request, 100 | type: MessageType.Request, 101 | payload: arg, 102 | }); 103 | return promise; 104 | }; 105 | }, 106 | } 107 | ) as H extends Handlers 108 | ? { 109 | [K in keyof H]: Parameters["length"] extends 0 110 | ? () => Promise>> 111 | : (arg: Parameters[0]) => Promise>>; 112 | } 113 | : never; 114 | } -------------------------------------------------------------------------------- /apps/docs/src/components/header-with-links.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { ComponentProps } from 'astro/types'; 3 | import config from 'virtual:starlight/user-config'; 4 | import { LinkButton } from '@astrojs/starlight/components' 5 | import { getAbsoluteLocaleUrl } from 'astro:i18n' 6 | 7 | import Header from '@astrojs/starlight/components/Header.astro'; 8 | import LanguageSelect from '@astrojs/starlight/components/LanguageSelect.astro'; 9 | import Search from '@astrojs/starlight/components/Search.astro'; 10 | import SiteTitle from '@astrojs/starlight/components/SiteTitle.astro'; 11 | import SocialIcons from '@astrojs/starlight/components/SocialIcons.astro'; 12 | import ThemeSelect from '@astrojs/starlight/components/ThemeSelect.astro'; 13 | 14 | import { DEFAULT_LOCALE } from '@/i18n'; 15 | 16 | export type Props = ComponentProps 17 | /** 18 | * Render the `Search` component if Pagefind is enabled or the default search component has been overridden. 19 | */ 20 | const shouldRenderSearch = 21 | config.pagefind || config.components.Search !== '@astrojs/starlight/components/Search.astro'; 22 | --- 23 | 24 |
25 |
26 | 27 |
28 |
29 | {shouldRenderSearch && } 30 |
31 |
32 | 38 | Web App 39 | 40 | 43 | 44 | 45 |
46 |
47 | 48 | 103 | -------------------------------------------------------------------------------- /packages/core/src/block-to-ascii/block-to-ascii.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import type { JSONPrimitive } from "../lib/json"; 4 | 5 | import { Block, CellType } from "../json-table"; 6 | import { makeTableFactory, makeTableInPlaceBaker } from "../json-to-table"; 7 | 8 | import { ASCIITableFormat, blockToASCII } from "./block-to-ascii"; 9 | 10 | import differentHeaders from "./__fixtures__/different-headers.json"; 11 | 12 | describe("blockToASCII", () => { 13 | const cornerCellValue = "№"; 14 | const factory = makeTableFactory({ cornerCellValue }); 15 | const bake = makeTableInPlaceBaker({ 16 | cornerCellValue, 17 | head: true, 18 | indexes: true, 19 | }); 20 | 21 | it("Should work with simple table", () => { 22 | const table: Block = { 23 | width: 1, 24 | height: 1, 25 | data: { 26 | rows: [ 27 | { 28 | cells: [ 29 | { 30 | type: CellType.Value, 31 | height: 1, 32 | width: 1, 33 | value: "a", 34 | }, 35 | ], 36 | columns: [0], 37 | }, 38 | ], 39 | indexes: [0], 40 | }, 41 | }; 42 | const ascii = blockToASCII(table); 43 | expect(`\n${ascii}`).toBe( 44 | ` 45 | +---+ 46 | | a | 47 | +---+` 48 | ); 49 | }); 50 | it("Should draw a simple markdown like table 1", () => { 51 | const table: Block = { 52 | width: 1, 53 | height: 1, 54 | data: { 55 | rows: [ 56 | { 57 | cells: [ 58 | { 59 | type: CellType.Value, 60 | height: 1, 61 | width: 1, 62 | value: "a", 63 | }, 64 | ], 65 | columns: [0], 66 | }, 67 | ], 68 | indexes: [0], 69 | }, 70 | }; 71 | const ascii = blockToASCII(table, { 72 | format: ASCIITableFormat.MarkdownLike, 73 | }); 74 | expect(ascii).toBe("| a |"); 75 | }); 76 | it("Should draw a simple markdown like table 2", () => { 77 | const table = factory({ 78 | a: 1, 79 | b: 2, 80 | } as any); 81 | const ascii = blockToASCII(bake(table), { 82 | format: ASCIITableFormat.MarkdownLike, 83 | }); 84 | expect(`\n${ascii}`).toBe( 85 | ` 86 | | a | b | 87 | |---|---| 88 | | 1 | 2 |` 89 | ); 90 | }); 91 | it("Should draw a simple markdown like table 3", () => { 92 | const table = factory([ 93 | { a: 1, b: 2 }, 94 | { a: 2, b: 3 }, 95 | { a: 3, b: 4 }, 96 | ]); 97 | const ascii = blockToASCII(bake(table), { 98 | format: ASCIITableFormat.MarkdownLike, 99 | }); 100 | expect(`\n${ascii}`).toBe( 101 | ` 102 | | № | a | b | 103 | |---|---|---| 104 | | 1 | 1 | 2 | 105 | | 2 | 2 | 3 | 106 | | 3 | 3 | 4 |` 107 | ); 108 | }); 109 | it("Should draw an invalid markdown like table", () => { 110 | const table = factory(differentHeaders as any); 111 | const baked = bake(table); 112 | const ascii = blockToASCII(baked, { 113 | format: ASCIITableFormat.MarkdownLike, 114 | }); 115 | expect(`\n${ascii}`).toBe(` 116 | | 1 | character_id | item_id | 117 | | |------------------------------|---------------------| 118 | |---| 5428010618020694593 | 95 | 119 | | 2 | character_id | item_id | stack_count | 120 | | | 5428010618020694593 | 101 | 4 |`); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "fenix": { 4 | "inputs": { 5 | "nixpkgs": [ 6 | "mk", 7 | "nixpkgs" 8 | ], 9 | "rust-analyzer-src": "rust-analyzer-src" 10 | }, 11 | "locked": { 12 | "lastModified": 1741934023, 13 | "narHash": "sha256-PMzzgtK4a70hpaUbjASuvSzLGjJP/3P7mGnqQOyTBiM=", 14 | "owner": "nix-community", 15 | "repo": "fenix", 16 | "rev": "4f956eacc9ec619bcd98f4580c663a8749978cc8", 17 | "type": "github" 18 | }, 19 | "original": { 20 | "owner": "nix-community", 21 | "repo": "fenix", 22 | "type": "github" 23 | } 24 | }, 25 | "flake-utils": { 26 | "inputs": { 27 | "systems": "systems" 28 | }, 29 | "locked": { 30 | "lastModified": 1731533236, 31 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 32 | "owner": "numtide", 33 | "repo": "flake-utils", 34 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 35 | "type": "github" 36 | }, 37 | "original": { 38 | "owner": "numtide", 39 | "repo": "flake-utils", 40 | "type": "github" 41 | } 42 | }, 43 | "mk": { 44 | "inputs": { 45 | "fenix": "fenix", 46 | "flake-utils": "flake-utils", 47 | "nixpkgs": "nixpkgs" 48 | }, 49 | "locked": { 50 | "lastModified": 1753826400, 51 | "narHash": "sha256-bR+F00lpAM4rJ+Nj4l5ST6kh4It4hP5IIiku7jJRC34=", 52 | "owner": "x0k", 53 | "repo": "mk", 54 | "rev": "057b75637e95b12c32575a2d31ce5c58da39e5c7", 55 | "type": "github" 56 | }, 57 | "original": { 58 | "owner": "x0k", 59 | "repo": "mk", 60 | "type": "github" 61 | } 62 | }, 63 | "nixpkgs": { 64 | "locked": { 65 | "lastModified": 1753489912, 66 | "narHash": "sha256-uDCFHeXdRIgJpYmtcUxGEsZ+hYlLPBhR83fdU+vbC1s=", 67 | "owner": "nixos", 68 | "repo": "nixpkgs", 69 | "rev": "13e8d35b7d6028b7198f8186bc0347c6abaa2701", 70 | "type": "github" 71 | }, 72 | "original": { 73 | "owner": "nixos", 74 | "ref": "nixos-25.05", 75 | "repo": "nixpkgs", 76 | "type": "github" 77 | } 78 | }, 79 | "nixpkgs_2": { 80 | "locked": { 81 | "lastModified": 1761999846, 82 | "narHash": "sha256-IYlYnp4O4dzEpL77BD/lj5NnJy2J8qbHkNSFiPBCbqo=", 83 | "owner": "nixos", 84 | "repo": "nixpkgs", 85 | "rev": "3de8f8d73e35724bf9abef41f1bdbedda1e14a31", 86 | "type": "github" 87 | }, 88 | "original": { 89 | "owner": "nixos", 90 | "ref": "nixos-25.05", 91 | "repo": "nixpkgs", 92 | "type": "github" 93 | } 94 | }, 95 | "root": { 96 | "inputs": { 97 | "mk": "mk", 98 | "nixpkgs": "nixpkgs_2" 99 | } 100 | }, 101 | "rust-analyzer-src": { 102 | "flake": false, 103 | "locked": { 104 | "lastModified": 1741895161, 105 | "narHash": "sha256-D8SLPa4vxA1EQdFi1SNJKeFOSCKk79hIv2l0ovfk2is=", 106 | "owner": "rust-lang", 107 | "repo": "rust-analyzer", 108 | "rev": "185f9deb452760f3abc2fde0500398e3198678cd", 109 | "type": "github" 110 | }, 111 | "original": { 112 | "owner": "rust-lang", 113 | "ref": "nightly", 114 | "repo": "rust-analyzer", 115 | "type": "github" 116 | } 117 | }, 118 | "systems": { 119 | "locked": { 120 | "lastModified": 1681028828, 121 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 122 | "owner": "nix-systems", 123 | "repo": "default", 124 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 125 | "type": "github" 126 | }, 127 | "original": { 128 | "owner": "nix-systems", 129 | "repo": "default", 130 | "type": "github" 131 | } 132 | } 133 | }, 134 | "root": "root", 135 | "version": 7 136 | } 137 | -------------------------------------------------------------------------------- /packages/block-to-xlsx/src/block-to-xlsx.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Cell as ExcelCell, 3 | type Column, 4 | type Row, 5 | type Worksheet, 6 | } from "exceljs"; 7 | 8 | import type { Matrix } from "@json-table/core/lib/matrix"; 9 | import { array } from "@json-table/core/lib/array"; 10 | import { max, sum } from "@json-table/core/lib/math"; 11 | import type { JSONPrimitive } from "@json-table/core/lib/json"; 12 | import { type Block, type Cell, CellType } from "@json-table/core"; 13 | import { createMatrix, fromMatrix } from "@json-table/core/block-matrix"; 14 | 15 | export interface MatrixData { 16 | cell: Cell; 17 | count: number; 18 | } 19 | 20 | export interface CalculateSheetDataOptions { 21 | columnWidth: ( 22 | column: MatrixData[], 23 | columnIndex: number, 24 | matrix: Matrix, 25 | table: Block 26 | ) => number; 27 | rowHeight: ( 28 | row: MatrixData[], 29 | rowIndex: number, 30 | matrix: Matrix, 31 | table: Block 32 | ) => number; 33 | } 34 | 35 | export function calculateSheetData( 36 | table: Block, 37 | { columnWidth, rowHeight }: CalculateSheetDataOptions 38 | ) { 39 | const { width } = table; 40 | const matrix = createMatrix(table, (cell) => ({ 41 | cell, 42 | count: 43 | typeof cell.value === "string" || typeof cell.value === "number" 44 | ? String(cell.value).length / cell.height / cell.width 45 | : 0, 46 | })); 47 | const cells = fromMatrix( 48 | matrix, 49 | (cell) => cell.cell.type, 50 | (cell, row, col) => ({ value: cell.cell.value, row: row + 1, col: col + 1 }) 51 | ).data.rows.flatMap((r) => r.cells); 52 | return { 53 | widths: array(width, (i) => 54 | columnWidth( 55 | matrix.map((row) => row[i]!), 56 | i, 57 | matrix, 58 | table 59 | ) 60 | ), 61 | heights: matrix.map((row, i) => rowHeight(row, i, matrix, table)), 62 | cells, 63 | }; 64 | } 65 | 66 | export type MakeWorkBookOptions = Partial< 67 | CalculateSheetDataOptions & { 68 | cellMinHeight: number; 69 | cellMinWidth: number; 70 | modifyColumn: (column: Column, columnIndex: number) => void; 71 | modifyRow: (row: Row, rowIndex: number) => void; 72 | modifyCell: ( 73 | sheetCell: ExcelCell, 74 | matrixCell: Cell<{ 75 | value: JSONPrimitive; 76 | col: number; 77 | row: number; 78 | }>, 79 | matrixCellIndex: number 80 | ) => void; 81 | } 82 | >; 83 | 84 | export function renderBlockOnWorksheet( 85 | sheet: Worksheet, 86 | block: Block, 87 | { 88 | cellMinHeight = 22, 89 | cellMinWidth = 10, 90 | modifyCell, 91 | modifyColumn, 92 | modifyRow, 93 | ...options 94 | }: MakeWorkBookOptions = {} 95 | ) { 96 | const { heights, widths, cells } = calculateSheetData(block, { 97 | columnWidth: (column) => { 98 | const counts = column.map((cell) => cell.count); 99 | return Math.max( 100 | Math.ceil(counts.reduce(sum) / block.height + counts.reduce(max)), 101 | cellMinWidth 102 | ); 103 | }, 104 | rowHeight: (row) => 105 | Math.max( 106 | Math.ceil( 107 | (row.map(({ count }) => count).reduce(sum) / block.width) * 2 108 | ), 109 | cellMinHeight 110 | ), 111 | ...options, 112 | }); 113 | widths.forEach((width, i) => { 114 | const column = sheet.getColumn(i + 1); 115 | column.width = width; 116 | modifyColumn?.(column, i + 1); 117 | }); 118 | heights.forEach((height, i) => { 119 | const row = sheet.getRow(i + 1); 120 | row.height = height; 121 | modifyRow?.(row, i + 1); 122 | }); 123 | cells.forEach((matrixCell, i) => { 124 | const { 125 | height, 126 | width, 127 | type, 128 | value: { col, row, value }, 129 | } = matrixCell; 130 | const sheetCell = sheet.getRow(row).getCell(col); 131 | sheetCell.value = value; 132 | sheetCell.alignment = { vertical: "middle", wrapText: true }; 133 | if (type !== CellType.Value) { 134 | sheetCell.font = { bold: true }; 135 | } 136 | modifyCell?.(sheetCell, matrixCell, i); 137 | if (height > 1 || width > 1) { 138 | sheet.mergeCells(row, col, row + height - 1, col + width - 1); 139 | } 140 | }); 141 | } 142 | -------------------------------------------------------------------------------- /packages/block-to-xlsx/src/block-to-xlsx.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { max, sum } from "@json-table/core/lib/math"; 4 | import { type Block, CellType } from "@json-table/core"; 5 | 6 | import { calculateSheetData } from "./block-to-xlsx"; 7 | 8 | describe("json-table-xlsx", () => { 9 | describe("calculateSheetData", () => { 10 | it("Should calculate sheet data", () => { 11 | const table: Block = { 12 | width: 4, 13 | height: 3, 14 | data: { 15 | rows: [ 16 | { 17 | cells: [ 18 | { 19 | height: 1, 20 | width: 1, 21 | value: "a", 22 | type: CellType.Header, 23 | }, 24 | { 25 | height: 1, 26 | width: 1, 27 | value: "b", 28 | type: CellType.Header, 29 | }, 30 | { 31 | height: 1, 32 | width: 2, 33 | value: "c", 34 | type: CellType.Header, 35 | }, 36 | ], 37 | columns: [0, 1, 2], 38 | }, 39 | { 40 | cells: [ 41 | { 42 | height: 2, 43 | width: 1, 44 | value: 1, 45 | type: CellType.Value, 46 | }, 47 | { 48 | height: 2, 49 | width: 1, 50 | value: 2, 51 | type: CellType.Value, 52 | }, 53 | { 54 | height: 1, 55 | width: 1, 56 | value: "aa", 57 | type: CellType.Header, 58 | }, 59 | { 60 | height: 1, 61 | width: 1, 62 | value: "bb", 63 | type: CellType.Header, 64 | }, 65 | ], 66 | columns: [0, 1, 2, 3], 67 | }, 68 | { 69 | cells: [ 70 | { 71 | height: 1, 72 | width: 1, 73 | value: 11, 74 | type: CellType.Value, 75 | }, 76 | { 77 | height: 1, 78 | width: 1, 79 | value: 22, 80 | type: CellType.Value, 81 | }, 82 | ], 83 | columns: [2, 3], 84 | }, 85 | ], 86 | indexes: [0, 1, 2], 87 | }, 88 | }; 89 | const expected = { 90 | widths: [8, 8, 8, 8], 91 | heights: [22, 22, 22], 92 | cells: [ 93 | { 94 | height: 1, 95 | width: 1, 96 | type: "header", 97 | value: { value: "a", row: 1, col: 1 }, 98 | }, 99 | { 100 | height: 1, 101 | width: 1, 102 | type: "header", 103 | value: { value: "b", row: 1, col: 2 }, 104 | }, 105 | { 106 | height: 1, 107 | width: 2, 108 | type: "header", 109 | value: { value: "c", row: 1, col: 3 }, 110 | }, 111 | { 112 | height: 2, 113 | width: 1, 114 | type: "value", 115 | value: { value: 1, row: 2, col: 1 }, 116 | }, 117 | { 118 | height: 2, 119 | width: 1, 120 | type: "value", 121 | value: { value: 2, row: 2, col: 2 }, 122 | }, 123 | { 124 | height: 1, 125 | width: 1, 126 | type: "header", 127 | value: { value: "aa", row: 2, col: 3 }, 128 | }, 129 | { 130 | height: 1, 131 | width: 1, 132 | type: "header", 133 | value: { value: "bb", row: 2, col: 4 }, 134 | }, 135 | { 136 | height: 1, 137 | width: 1, 138 | type: "value", 139 | value: { value: 11, row: 3, col: 3 }, 140 | }, 141 | { 142 | height: 1, 143 | width: 1, 144 | type: "value", 145 | value: { value: 22, row: 3, col: 4 }, 146 | }, 147 | ], 148 | }; 149 | expect( 150 | calculateSheetData(table, { 151 | columnWidth: (column) => { 152 | const counts = column.map((cell) => cell.count); 153 | return Math.max( 154 | Math.ceil( 155 | (counts.reduce(sum) / table.height + counts.reduce(max)) / 2 156 | ), 157 | 8 158 | ); 159 | }, 160 | rowHeight: (row) => 161 | Math.max( 162 | Math.ceil( 163 | row.map(({ count }) => count).reduce(sum) / table.width 164 | ), 165 | 22 166 | ), 167 | }) 168 | ).toEqual(expected); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /apps/web/src/model.ts: -------------------------------------------------------------------------------- 1 | import type { Schema, UiSchemaRoot } from "@sjsf/form"; 2 | 3 | import { 4 | ASCIITableFormat, 5 | ASCII_TABLE_FORMATS, 6 | } from "@json-table/core/block-to-ascii"; 7 | 8 | import { OutputFormat, TransformPreset, type TransformConfig } from "./core"; 9 | 10 | export interface SharedData { 11 | data: string; 12 | options: TransformConfig; 13 | createOnOpen: boolean; 14 | } 15 | 16 | export enum ShareBehavior { 17 | CreateOnOpen = "createOnOpen", 18 | OpenEditor = "openEditor", 19 | } 20 | 21 | export enum SourceType { 22 | Text = "text", 23 | File = "file", 24 | URL = "url", 25 | } 26 | 27 | export type Source = { 28 | data: string; 29 | } & ( 30 | | { 31 | type: SourceType.Text; 32 | } 33 | | { 34 | type: SourceType.File; 35 | fileName: string; 36 | } 37 | | { 38 | type: SourceType.URL; 39 | } 40 | ); 41 | 42 | export function createShareUrl( 43 | compress: (data: string) => string, 44 | data: SharedData 45 | ) { 46 | const encoded = compress(JSON.stringify(data)); 47 | const url = new URL(location.href); 48 | url.hash = encoded; 49 | return url.toString(); 50 | } 51 | 52 | export function makeSource(type: SourceType): Source { 53 | switch (type) { 54 | case SourceType.File: 55 | return { 56 | type, 57 | data: "", 58 | fileName: "", 59 | }; 60 | default: 61 | return { 62 | type, 63 | data: "", 64 | }; 65 | } 66 | } 67 | 68 | export function fetchAsText(url: string): Promise { 69 | return fetch(url).then((r) => r.text()); 70 | } 71 | 72 | export async function resolveSource(source: Source): Promise { 73 | switch (source.type) { 74 | case SourceType.Text: 75 | return source.data; 76 | case SourceType.File: 77 | return source.data; 78 | case SourceType.URL: 79 | return await fetchAsText(source.data); 80 | } 81 | } 82 | 83 | export const TRANSFORM_SCHEMA: Schema = { 84 | type: "object", 85 | title: "Options", 86 | properties: { 87 | preset: { 88 | title: "Preset", 89 | type: "string", 90 | enum: Object.values(TransformPreset), 91 | default: TransformPreset.Default, 92 | }, 93 | transform: { 94 | title: "Transform", 95 | description: "Apply a transformation to the output data", 96 | type: "boolean", 97 | default: false, 98 | }, 99 | format: { 100 | title: "Output format", 101 | type: "string", 102 | enum: Object.values(OutputFormat), 103 | default: OutputFormat.HTML, 104 | }, 105 | paginate: { 106 | title: "Paginate", 107 | description: 108 | "Partitioning the input data (object or array) into pages by their keys", 109 | type: "boolean", 110 | default: false, 111 | }, 112 | }, 113 | required: ["preset", "format"], 114 | dependencies: { 115 | preset: { 116 | oneOf: [ 117 | { 118 | properties: { 119 | preset: { 120 | const: TransformPreset.Default, 121 | }, 122 | }, 123 | }, 124 | { 125 | properties: { 126 | preset: { 127 | const: TransformPreset.Manual, 128 | }, 129 | collapseIndexes: { 130 | title: "Combine nested indexes", 131 | description: 132 | "Combines hierarchical indexes into one cell (1.1, 1.2, ...)", 133 | type: "boolean", 134 | default: true, 135 | }, 136 | joinPrimitiveArrayValues: { 137 | title: "Combine simple values", 138 | description: 139 | "Combines the values of an array of primitives into one cell (separated by ',')", 140 | type: "boolean", 141 | default: true, 142 | }, 143 | combineArraysOfObjects: { 144 | title: "Combine objects", 145 | description: "Combine arrays of objects into a single object", 146 | type: "boolean", 147 | default: false, 148 | }, 149 | stabilizeOrderOfPropertiesInArraysOfObjects: { 150 | title: "Stabilize order of properties", 151 | description: 152 | "Stabilizing the order in which properties are displayed for arrays of objects", 153 | type: "boolean", 154 | default: true, 155 | }, 156 | proportionalSizeAdjustmentThreshold: { 157 | title: "Proportional size adjustment threshold", 158 | description: 159 | "Specifies the threshold to which the value (height, width) can be increased for a proportional increase. Default is 1 (by 100%).", 160 | type: "number", 161 | minimum: 0, 162 | default: 1, 163 | }, 164 | cornerCellValue: { 165 | title: "Corner cell value", 166 | description: "The value of the corner cell.", 167 | type: "string", 168 | default: "№", 169 | }, 170 | }, 171 | required: ["proportionalSizeAdjustmentThreshold"], 172 | }, 173 | ], 174 | }, 175 | transform: { 176 | oneOf: [ 177 | { 178 | properties: { 179 | transform: { 180 | const: false, 181 | }, 182 | }, 183 | }, 184 | { 185 | properties: { 186 | transform: { 187 | const: true, 188 | }, 189 | horizontalReflect: { 190 | type: "boolean", 191 | title: "Reflect horizontally", 192 | default: false, 193 | }, 194 | verticalReflect: { 195 | type: "boolean", 196 | title: "Reflect vertically", 197 | default: false, 198 | }, 199 | transpose: { 200 | type: "boolean", 201 | title: "Transpose", 202 | default: false, 203 | }, 204 | }, 205 | }, 206 | ], 207 | }, 208 | format: { 209 | oneOf: [ 210 | { 211 | properties: { 212 | format: { 213 | const: OutputFormat.HTML, 214 | }, 215 | }, 216 | }, 217 | { 218 | properties: { 219 | format: { 220 | const: OutputFormat.XLSX, 221 | }, 222 | }, 223 | }, 224 | { 225 | properties: { 226 | format: { 227 | const: OutputFormat.ASCII, 228 | }, 229 | asciiFormat: { 230 | type: "string", 231 | title: "ASCII table format", 232 | enum: ASCII_TABLE_FORMATS, 233 | default: ASCIITableFormat.MySQL, 234 | }, 235 | }, 236 | }, 237 | ], 238 | }, 239 | }, 240 | }; 241 | 242 | export const TRANSFORM_UI_SCHEMA: UiSchemaRoot = { 243 | "ui:options": { 244 | order: [ 245 | "preset", 246 | "collapseIndexes", 247 | "joinPrimitiveArrayValues", 248 | "combineArraysOfObjects", 249 | "stabilizeOrderOfPropertiesInArraysOfObjects", 250 | "proportionalSizeAdjustmentThreshold", 251 | "cornerCellValue", 252 | "transform", 253 | "horizontalReflect", 254 | "verticalReflect", 255 | "transpose", 256 | "format", 257 | "asciiFormat", 258 | "paginate", 259 | ], 260 | }, 261 | }; 262 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/json-to-table.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type JSONPrimitive, 3 | type JSONRecord, 4 | type JSONValue, 5 | isJsonPrimitive, 6 | } from "../lib/json.js"; 7 | import { array } from "../lib/array.js"; 8 | import { isRecord } from "../lib/object.js"; 9 | 10 | import { 11 | type ComposedTable, 12 | type Cells, 13 | CellType, 14 | type Block, 15 | type Table, 16 | } from "../json-table.js"; 17 | 18 | import { 19 | mergeBlocksHorizontally, 20 | shiftPositionsInPlace, 21 | } from "../block/index.js"; 22 | import { 23 | makeTableFromValue, 24 | makeTableInPlaceBaker, 25 | makeTableInPlaceStacker, 26 | } from "./table.js"; 27 | import { makeObjectPropertiesStabilizer } from "./properties-stabilizer.js"; 28 | import { makeProportionalResizeGuard } from "./proportional-resize-guard.js"; 29 | 30 | export interface TableFactoryOptions { 31 | cornerCellValue: V; 32 | joinPrimitiveArrayValues?: boolean; 33 | /** combine arrays of objects into a single object */ 34 | combineArraysOfObjects?: boolean; 35 | /** proportional size adjustment threshold */ 36 | proportionalSizeAdjustmentThreshold?: number; 37 | collapseIndexes?: boolean; 38 | stabilizeOrderOfPropertiesInArraysOfObjects?: boolean; 39 | } 40 | 41 | export function makeTableFactory({ 42 | cornerCellValue, 43 | joinPrimitiveArrayValues, 44 | combineArraysOfObjects, 45 | proportionalSizeAdjustmentThreshold = 1, 46 | collapseIndexes, 47 | stabilizeOrderOfPropertiesInArraysOfObjects = true, 48 | }: TableFactoryOptions) { 49 | const isProportionalResize = makeProportionalResizeGuard( 50 | proportionalSizeAdjustmentThreshold 51 | ); 52 | const verticalTableInPlaceStacker = makeTableInPlaceStacker({ 53 | deduplicationComponent: "head", 54 | isProportionalResize, 55 | cornerCellValue, 56 | }); 57 | const horizontalTableInPlaceStacker = makeTableInPlaceStacker({ 58 | deduplicationComponent: "indexes", 59 | isProportionalResize, 60 | cornerCellValue, 61 | }); 62 | 63 | function addIndexesInPlace(table: ComposedTable, titles: string[]): void { 64 | const { baked, indexes: indexesBlock } = table; 65 | const hasIndexes = indexesBlock !== null; 66 | const collapse = hasIndexes && collapseIndexes; 67 | if (collapse) { 68 | let blockIndex = 0; 69 | let h = baked[0]!.height; 70 | const { rows, indexes } = indexesBlock.data; 71 | for (let i = 0; i < rows.length; i++) { 72 | const rawRow = rows[i]!; 73 | const index = indexes[i]!; 74 | if (index >= h) { 75 | h += baked[++blockIndex]!.height; 76 | } 77 | const title = titles[blockIndex]; 78 | rawRow.cells[0]!.value = `${title}.${rawRow.cells[0]!.value}`; 79 | } 80 | return; 81 | } 82 | const rawRows = array(baked.length, () => ({ 83 | cells: [], 84 | columns: [], 85 | })); 86 | const idx = new Array(0); 87 | let index = 0; 88 | for (let i = 0; i < baked.length; i++) { 89 | const rawRow = rawRows[i]!; 90 | const { height } = baked[i]!; 91 | rawRow.cells.push({ 92 | height: height, 93 | width: 1, 94 | value: titles[i]!, 95 | type: CellType.Index, 96 | }); 97 | rawRow.columns.push(0); 98 | idx.push(index); 99 | index += height; 100 | } 101 | const newIndexes: Block = { 102 | height: index, 103 | width: 1, 104 | data: { 105 | rows: rawRows, 106 | indexes: idx, 107 | }, 108 | }; 109 | table.indexes = hasIndexes 110 | ? mergeBlocksHorizontally([newIndexes, indexesBlock], index) 111 | : newIndexes; 112 | } 113 | 114 | function addHeaders(table: ComposedTable, titles: string[]): void { 115 | const { baked, head } = table; 116 | const hasHeaders = head !== null; 117 | const newHead: Cells = { 118 | cells: [], 119 | columns: [], 120 | }; 121 | let w = 0; 122 | for (let i = 0; i < baked.length; i++) { 123 | const { width } = baked[i]!; 124 | newHead.cells.push({ 125 | height: 1, 126 | width, 127 | value: titles[i]!, 128 | type: CellType.Header, 129 | }); 130 | newHead.columns.push(w); 131 | w += width; 132 | } 133 | if (hasHeaders) { 134 | head.data.rows.unshift(newHead); 135 | shiftPositionsInPlace(head.data.indexes, 1); 136 | head.data.indexes.unshift(0); 137 | } 138 | table.head = { 139 | width: w, 140 | height: hasHeaders ? head.height + 1 : 1, 141 | data: hasHeaders ? head.data : { rows: [newHead], indexes: [0] }, 142 | }; 143 | } 144 | 145 | function stackTablesVertical(titles: string[], tables: Table[]): Table { 146 | const stacked = verticalTableInPlaceStacker(tables); 147 | addIndexesInPlace(stacked, titles); 148 | // @ts-expect-error transform to regular table 149 | delete stacked.baked; 150 | return stacked; 151 | } 152 | function stackTablesHorizontal(titles: string[], tables: Table[]): Table { 153 | const stacked = horizontalTableInPlaceStacker(tables); 154 | addHeaders(stacked, titles); 155 | // @ts-expect-error transform to regular table 156 | delete stacked.baked; 157 | return stacked; 158 | } 159 | function transformRecord(record: Record): Table { 160 | const keys = Object.keys(record); 161 | if (keys.length === 0) { 162 | return makeTableFromValue(""); 163 | } 164 | return stackTablesHorizontal( 165 | keys, 166 | keys.map((key) => transformValue(record[key]!)) 167 | ); 168 | } 169 | function transformArray( 170 | value: V[], 171 | transformValue: (value: V) => Table 172 | ): Table { 173 | const titles = new Array(value.length); 174 | const tables = new Array(value.length); 175 | for (let i = 0; i < value.length; i++) { 176 | titles[i] = String(i + 1); 177 | tables[i] = transformValue(value[i]!); 178 | } 179 | return stackTablesVertical(titles, tables); 180 | } 181 | function transformValue(value: JSONValue): Table { 182 | if (isJsonPrimitive(value)) { 183 | return makeTableFromValue(value); 184 | } 185 | if (Array.isArray(value)) { 186 | if (value.length === 0) { 187 | return makeTableFromValue(""); 188 | } 189 | let isPrimitives = true; 190 | let isRecords = true; 191 | let i = 0; 192 | while (i < value.length && (isPrimitives || isRecords)) { 193 | isPrimitives = isPrimitives && isJsonPrimitive(value[i]!); 194 | isRecords = isRecords && isRecord(value[i]); 195 | i++; 196 | } 197 | if (joinPrimitiveArrayValues && isPrimitives) { 198 | return makeTableFromValue(value.join(", ")); 199 | } 200 | if (combineArraysOfObjects && isRecords) { 201 | return transformRecord(Object.assign({}, ...value)); 202 | } 203 | if (stabilizeOrderOfPropertiesInArraysOfObjects && isRecords) { 204 | const stabilize = makeObjectPropertiesStabilizer(); 205 | return transformArray(value as JSONRecord[], (value) => { 206 | const [keys, values] = stabilize(value); 207 | if (keys.length === 0) { 208 | return makeTableFromValue(""); 209 | } 210 | return stackTablesHorizontal(keys, values.map(transformValue)); 211 | }); 212 | } 213 | return transformArray(value, transformValue); 214 | } 215 | return transformRecord(value); 216 | } 217 | return transformValue; 218 | } 219 | 220 | export function makeBlockFactory(options: TableFactoryOptions) { 221 | const makeTable = makeTableFactory(options); 222 | const bake = makeTableInPlaceBaker({ 223 | cornerCellValue: options.cornerCellValue, 224 | head: true, 225 | indexes: true, 226 | }); 227 | return (value: JSONValue) => bake(makeTable(value)); 228 | } 229 | -------------------------------------------------------------------------------- /packages/core/src/block-to-ascii/block-to-ascii.ts: -------------------------------------------------------------------------------- 1 | import { array } from "../lib/array.js"; 2 | import { type Matrix, matrix } from "../lib/matrix.js"; 3 | 4 | import { type Block, type Cell, CellType } from "../json-table.js"; 5 | import { createMatrix } from "../block-matrix.js"; 6 | 7 | function getMaxLineLength(rows: string[]) { 8 | let max = 0; 9 | for (let i = 0; i < rows.length; i++) { 10 | max = Math.max(max, rows[i]!.length); 11 | } 12 | return max; 13 | } 14 | 15 | function padCellRow(row: string, w: number, cell: Cell, rows: string[]) { 16 | switch (cell.type) { 17 | case CellType.Corner: 18 | case CellType.Header: 19 | case CellType.Index: { 20 | const p = Math.floor((w - row.length) / 2); 21 | return (p > 0 ? row.padStart(p + row.length) : row).padEnd(w); 22 | } 23 | default: { 24 | if (rows.length === 1 && !isNaN(Number(row))) { 25 | return row.padStart(w); 26 | } 27 | return row.padEnd(w); 28 | } 29 | } 30 | } 31 | 32 | export enum ASCIITableFormat { 33 | MySQL = "MySql", 34 | MarkdownLike = "Markdown Like", 35 | } 36 | 37 | export const ASCII_TABLE_FORMATS = Object.values(ASCIITableFormat); 38 | 39 | export interface BlockToASCIIOptions { 40 | format?: ASCIITableFormat; 41 | } 42 | 43 | interface InputCell { 44 | cell: Cell; 45 | rowIndex: number; 46 | collIndex: number; 47 | lines: string[]; 48 | maxRowLength: number; 49 | } 50 | 51 | function populateShifts( 52 | block: Block, 53 | inputMatrix: Matrix, 54 | xShift: number[], 55 | yShift: number[] 56 | ) { 57 | for (let i = 0; i < block.height; i++) { 58 | for (let j = 0; j < block.width; j++) { 59 | const cell = inputMatrix[i]![j]!; 60 | if (cell.collIndex === j) { 61 | xShift[j + 1] = Math.max( 62 | xShift[j + 1]!, 63 | Math.max(cell.maxRowLength - cell.cell.width, 0) + 1 64 | ); 65 | } 66 | if (cell.rowIndex === i) { 67 | yShift[i + 1] = Math.max( 68 | yShift[i + 1]!, 69 | Math.max(cell.lines.length - cell.cell.height, 0) + 1 70 | ); 71 | } 72 | } 73 | } 74 | } 75 | 76 | function populateMySqlShifts( 77 | block: Block, 78 | inputMatrix: Matrix, 79 | xShift: number[], 80 | yShift: number[] 81 | ) { 82 | xShift[0] = yShift[0] = 1; 83 | populateShifts(block, inputMatrix, xShift, yShift); 84 | } 85 | 86 | function populateMarkdownLikeShifts( 87 | block: Block, 88 | inputMatrix: Matrix, 89 | xShift: number[], 90 | yShift: number[] 91 | ) { 92 | xShift[0] = 1; 93 | populateShifts(block, inputMatrix, xShift, yShift); 94 | for (let i = 2; i < inputMatrix.length; i++) { 95 | yShift[i]! -= 1; 96 | } 97 | } 98 | 99 | function drawMySqlBorder( 100 | outMatrix: Matrix, 101 | width: number, 102 | height: number 103 | ) { 104 | for (let i = 0; i < height; i++) { 105 | for (let j = 0; j < width; j++) { 106 | const cell = outMatrix[i]![j]!; 107 | if (cell !== null) { 108 | continue; 109 | } 110 | const isLeftEdge = j === 0; 111 | const isTopEdge = i === 0; 112 | const isRightEdge = j === width - 1; 113 | const isBottomEdge = i === height - 1; 114 | const previous = !isLeftEdge && outMatrix[i]![j - 1]!; 115 | const next = !isRightEdge && outMatrix[i]![j + 1]!; 116 | const beneath = !isBottomEdge && outMatrix[i + 1]![j]!; 117 | const above = !isTopEdge && outMatrix[i - 1]![j]!; 118 | if ( 119 | ((isLeftEdge || isRightEdge) && (isTopEdge || isBottomEdge)) || 120 | (previous === "-" && beneath === null) || 121 | (above === "|" && next === null) || 122 | (previous === "-" && above === "|") 123 | ) { 124 | outMatrix[i]![j] = "+"; 125 | continue; 126 | } 127 | if (previous === "+" || previous === "-") { 128 | outMatrix[i]![j] = "-"; 129 | continue; 130 | } 131 | if (above === "+" || above === "|") { 132 | outMatrix[i]![j] = "|"; 133 | continue; 134 | } 135 | outMatrix[i]![j] = "n"; 136 | } 137 | } 138 | } 139 | 140 | function drawMarkdownLikeBorder( 141 | outMatrix: Matrix, 142 | width: number, 143 | height: number 144 | ) { 145 | outMatrix.splice(height - 1, 1); 146 | for (let i = 0; i < height - 1; i++) { 147 | for (let j = 0; j < width; j++) { 148 | const cell = outMatrix[i]![j]; 149 | if (cell !== null) { 150 | continue; 151 | } 152 | const isLeftEdge = j === 0; 153 | const isTopEdge = i === 0; 154 | // const isRightEdge = j === width - 1; 155 | // const isBottomEdge = i === height - 1; 156 | const previous = !isLeftEdge && outMatrix[i]![j - 1]; 157 | const above = !isTopEdge && outMatrix[i - 1]![j]; 158 | // const next = !isRightEdge && outMatrix[i][j + 1]; 159 | // const beneath = !isBottomEdge && outMatrix[i + 1][j]; 160 | if ((previous === "|" || previous === "-") && above !== "|") { 161 | outMatrix[i]![j] = "-"; 162 | continue; 163 | } 164 | outMatrix[i]![j] = "|"; 165 | } 166 | } 167 | } 168 | 169 | const SHIFTS_POPULATORS = { 170 | [ASCIITableFormat.MySQL]: populateMySqlShifts, 171 | [ASCIITableFormat.MarkdownLike]: populateMarkdownLikeShifts, 172 | }; 173 | 174 | const BORDER_DRAWERS = { 175 | [ASCIITableFormat.MySQL]: drawMySqlBorder, 176 | [ASCIITableFormat.MarkdownLike]: drawMarkdownLikeBorder, 177 | }; 178 | 179 | export function blockToASCII( 180 | block: Block, 181 | { format = ASCIITableFormat.MySQL }: BlockToASCIIOptions = {} 182 | ) { 183 | const inputMatrix = createMatrix( 184 | block, 185 | (cell, rowIndex, collIndex): InputCell => { 186 | const content = 187 | typeof cell.value === "string" 188 | ? cell.value 189 | : JSON.stringify(cell.value, null, 2); 190 | const lines = content.split("\n").map((r) => ` ${r.trim()} `); 191 | return { 192 | cell, 193 | rowIndex, 194 | collIndex, 195 | lines, 196 | maxRowLength: getMaxLineLength(lines), 197 | }; 198 | } 199 | ); 200 | const xShift = array(block.width + 1, () => 0); 201 | const yShift = array(block.height + 1, () => 0); 202 | SHIFTS_POPULATORS[format](block, inputMatrix, xShift, yShift); 203 | // Accumulate 204 | for (let i = 1; i <= block.width; i++) { 205 | xShift[i]! += xShift[i - 1]!; 206 | } 207 | for (let i = 1; i <= block.height; i++) { 208 | yShift[i]! += yShift[i - 1]!; 209 | } 210 | const height = block.height + yShift[block.height]!; 211 | const width = block.width + xShift[block.width]!; 212 | const outMatrix = matrix(height, width, () => null); 213 | const placed = new Set(); 214 | for (let i = 0; i < block.height; i++) { 215 | for (let j = 0; j < block.width; j++) { 216 | const { cell, lines } = inputMatrix[i]![j]!; 217 | if (placed.has(cell)) { 218 | continue; 219 | } 220 | placed.add(cell); 221 | const rowIndex = i + yShift[i]!; 222 | const colIndex = j + xShift[j]!; 223 | // TODO: This `||` is a hack and the `splice` in the markdown-like border fn also 224 | // I think there is a general way to compute sizes 225 | const h = cell.height + yShift[i + cell.height]! - yShift[i]! - 1 || 1; 226 | const w = cell.width + xShift[j + cell.width]! - xShift[j]! - 1 || 1; 227 | const startRow = Math.floor((h - lines.length) / 2); 228 | const endRow = startRow + lines.length; 229 | for (let y = 0; y < h; y++) { 230 | const c = y + rowIndex; 231 | if (y >= startRow && y < endRow) { 232 | const row = padCellRow(lines[y - startRow]!, w, cell, lines); 233 | for (let x = 0; x < w; x++) { 234 | outMatrix[c]![x + colIndex] = row[x]!; 235 | } 236 | } else { 237 | for (let x = 0; x < w; x++) { 238 | outMatrix[c]![x + colIndex] = " "; 239 | } 240 | } 241 | } 242 | } 243 | } 244 | BORDER_DRAWERS[format](outMatrix, width, height); 245 | return outMatrix.map((row) => row.join("")).join("\n"); 246 | } 247 | -------------------------------------------------------------------------------- /apps/web/src/main-page.svelte: -------------------------------------------------------------------------------- 1 | 113 | 114 | 115 | {#snippet append()} 116 | 122 | 127 | 140 | 141 | 142 | 148 | 158 | 159 | 160 | {/snippet} 161 |
162 |

Source type:

163 | {#each Object.values(SourceType) as type} 164 |
165 | 177 |
178 | {/each} 179 | 188 |
189 | {#if source.type === SourceType.Text} 190 | 196 |
197 |

Examples:

198 | 199 | 202 | 203 | 204 |
205 | {:else if source.type === SourceType.File} 206 | 216 | {:else if source.type === SourceType.URL} 217 | 223 | {/if} 224 |
225 | 226 | 227 | 228 |
229 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/table.ts: -------------------------------------------------------------------------------- 1 | import { lcm, max } from "../lib/math.js"; 2 | 3 | import { 4 | type Block, 5 | type BlockCompositor, 6 | CellType, 7 | TABLE_COMPONENT_SIZE_ASPECTS, 8 | TABLE_COMPONENT_OPPOSITES, 9 | type Table, 10 | type TableComponent, 11 | type TableCompositor, 12 | type ComposedTable, 13 | type ProportionalResizeGuard, 14 | } from "../json-table.js"; 15 | 16 | import { 17 | shiftPositionsInPlace, 18 | areBlocksEqual, 19 | makeBlockInPlaceScaler, 20 | makeHorizontalBlockInPlaceStacker, 21 | makeVerticalBlockInPlaceStacker, 22 | mergeBlocksHorizontally, 23 | mergeBlocksVertically, 24 | } from "../block/index.js"; 25 | 26 | export function makeTableFromValue(value: V): Table { 27 | return { 28 | head: null, 29 | indexes: null, 30 | body: { 31 | height: 1, 32 | width: 1, 33 | data: { 34 | rows: [ 35 | { 36 | cells: [{ height: 1, width: 1, value, type: CellType.Value }], 37 | columns: [0], 38 | }, 39 | ], 40 | indexes: [0], 41 | }, 42 | }, 43 | }; 44 | } 45 | 46 | export interface BakeOptions { 47 | head?: boolean; 48 | indexes?: boolean; 49 | cornerCellValue: V; 50 | } 51 | 52 | function bestHead(a: Block, b: Block) { 53 | if (a.width === b.width) { 54 | return a.height < b.height ? a : b; 55 | } 56 | return a.width > b.width ? a : b; 57 | } 58 | 59 | function bestIndexes(a: Block, b: Block) { 60 | if (a.height === b.height) { 61 | return a.width < b.width ? a : b; 62 | } 63 | return a.height > b.height ? a : b; 64 | } 65 | 66 | const TABLE_COMPONENT_SELECTORS: Record< 67 | TableComponent, 68 | (a: Block, b: Block) => Block 69 | > = { 70 | head: bestHead, 71 | indexes: bestIndexes, 72 | }; 73 | 74 | export function tryDeduplicateComponent( 75 | tables: Table[], 76 | component: TableComponent, 77 | proportionalResizeGuard: ProportionalResizeGuard 78 | ): Block | null { 79 | const { [component]: cmp } = tables[0]!; 80 | if (!cmp) { 81 | return null; 82 | } 83 | const select = TABLE_COMPONENT_SELECTORS[component]; 84 | const blocks = [cmp]; 85 | let bestCmp = cmp; 86 | let lcmHeight = cmp.height; 87 | let lcmWidth = cmp.width; 88 | let maxHeight = cmp.height; 89 | let maxWidth = cmp.width; 90 | for (let i = 1; i < tables.length; i++) { 91 | const cmp = tables[i]![component]; 92 | if (!cmp) { 93 | return null; 94 | } 95 | bestCmp = select(bestCmp, cmp); 96 | maxHeight = max(maxHeight, cmp.height); 97 | maxWidth = max(maxWidth, cmp.width); 98 | lcmHeight = lcm(lcmHeight, cmp.height); 99 | lcmWidth = lcm(lcmWidth, cmp.width); 100 | blocks.push(cmp); 101 | } 102 | const isHeightProportional = proportionalResizeGuard(lcmHeight, maxHeight); 103 | const isWidthProportional = proportionalResizeGuard(lcmWidth, maxWidth); 104 | if ( 105 | !(component === "head" ? isHeightProportional : isWidthProportional) || 106 | !areBlocksEqual({ 107 | blocks, 108 | width: isWidthProportional ? lcmWidth : maxWidth, 109 | widthIsLcm: isWidthProportional, 110 | height: isHeightProportional ? lcmHeight : maxHeight, 111 | heightIsLcm: isHeightProportional, 112 | }) 113 | ) { 114 | return null; 115 | } 116 | return bestCmp; 117 | } 118 | 119 | export function makeTableInPlaceBaker({ 120 | head: bakeHead, 121 | indexes: bakeIndexes, 122 | cornerCellValue, 123 | }: BakeOptions) { 124 | return ({ body, head, indexes }: Table) => { 125 | if (!bakeHead && !bakeIndexes) { 126 | return body; 127 | } 128 | const useHead = bakeHead && head !== null; 129 | const useIndexes = bakeIndexes && indexes !== null; 130 | const withIndexes = useIndexes 131 | ? mergeBlocksHorizontally([indexes, body], body.height) 132 | : body; 133 | const width = body.width + (useIndexes ? indexes.width : 0); 134 | if (!useHead) { 135 | return withIndexes; 136 | } 137 | if (!useIndexes) { 138 | return mergeBlocksVertically([head, withIndexes], width); 139 | } 140 | // TODO: factor out `prependCell(Block, Cell)` ? 141 | for (let i = 0; i < head.data.rows.length; i++) { 142 | shiftPositionsInPlace(head.data.rows[i]!.columns, indexes.width); 143 | } 144 | const firstHeadRow = head.data.rows[0]!; 145 | firstHeadRow.cells.unshift({ 146 | height: head.height, 147 | width: indexes.width, 148 | value: cornerCellValue, 149 | type: CellType.Corner, 150 | }); 151 | firstHeadRow.columns.unshift(0); 152 | return mergeBlocksVertically([head, withIndexes], width); 153 | }; 154 | } 155 | 156 | export function tryPrepareTablesToStack( 157 | tables: Table[], 158 | component: TableComponent, 159 | bake: boolean, 160 | cornerCellValue: V 161 | ) { 162 | const blocks: Block[] = []; 163 | for (let i = 0; i < tables.length; i++) { 164 | const table = tables[i]!; 165 | const { 166 | [component]: cmp, 167 | [TABLE_COMPONENT_OPPOSITES[component]]: opposite, 168 | } = table; 169 | if (cmp === null) { 170 | return null; 171 | } 172 | if (!bake || !opposite) { 173 | blocks.push(cmp); 174 | continue; 175 | } 176 | switch (component) { 177 | case "indexes": { 178 | const shifted = cmp.data.indexes.slice(); 179 | // mutation of local copy 180 | shiftPositionsInPlace(shifted, opposite.height); 181 | shifted.unshift(0); 182 | blocks.push({ 183 | height: cmp.height + opposite.height, 184 | width: cmp.width, 185 | data: { 186 | rows: [ 187 | { 188 | cells: [ 189 | { 190 | height: opposite.height, 191 | width: cmp.width, 192 | value: cornerCellValue, 193 | type: CellType.Corner, 194 | }, 195 | ], 196 | columns: [0], 197 | }, 198 | ...cmp.data.rows, 199 | ], 200 | indexes: shifted, 201 | }, 202 | }); 203 | break; 204 | } 205 | case "head": { 206 | const shifted = cmp.data.rows[0]!.columns.slice(); 207 | // mutation of local copy 208 | shiftPositionsInPlace(shifted, opposite.width); 209 | shifted.unshift(0); 210 | blocks.push({ 211 | height: cmp.height, 212 | width: cmp.width + opposite.width, 213 | data: { 214 | rows: [ 215 | { 216 | cells: [ 217 | { 218 | height: cmp.height, 219 | width: opposite.width, 220 | value: cornerCellValue, 221 | type: CellType.Corner, 222 | }, 223 | ...cmp.data.rows[0]!.cells, 224 | ], 225 | columns: shifted, 226 | }, 227 | ...cmp.data.rows.slice(1), 228 | ], 229 | indexes: cmp.data.indexes, 230 | }, 231 | }); 232 | break; 233 | } 234 | default: 235 | throw new Error(`Unknown table component: ${component}`); 236 | } 237 | } 238 | return blocks; 239 | } 240 | 241 | export interface TableStackerOptions { 242 | isProportionalResize: ProportionalResizeGuard; 243 | cornerCellValue: V; 244 | deduplicationComponent: C; 245 | } 246 | 247 | const TABLE_COMPONENT_TO_BLOCK_IN_PLACE_STACKERS: Record< 248 | TableComponent, 249 | (guard: ProportionalResizeGuard) => BlockCompositor 250 | > = { 251 | head: makeVerticalBlockInPlaceStacker, 252 | indexes: makeHorizontalBlockInPlaceStacker, 253 | }; 254 | 255 | export function makeTableInPlaceStacker({ 256 | deduplicationComponent, 257 | isProportionalResize, 258 | cornerCellValue, 259 | }: TableStackerOptions): TableCompositor { 260 | const blockInPlaceStacker = 261 | TABLE_COMPONENT_TO_BLOCK_IN_PLACE_STACKERS[deduplicationComponent]( 262 | isProportionalResize 263 | ); 264 | const opposite = TABLE_COMPONENT_OPPOSITES[deduplicationComponent]; 265 | return (tables) => { 266 | const deduplicated = tryDeduplicateComponent( 267 | tables, 268 | deduplicationComponent, 269 | isProportionalResize 270 | ); 271 | const bake = deduplicated === null; 272 | const blocksToStack = tryPrepareTablesToStack( 273 | tables, 274 | opposite, 275 | bake, 276 | cornerCellValue 277 | ); 278 | const baked = tables.map( 279 | makeTableInPlaceBaker({ 280 | [deduplicationComponent]: bake, 281 | [opposite]: blocksToStack === null, 282 | cornerCellValue, 283 | }) 284 | ); 285 | const body = blockInPlaceStacker(baked); 286 | const aspect = TABLE_COMPONENT_SIZE_ASPECTS[opposite]; 287 | if (deduplicated) { 288 | const scale = makeBlockInPlaceScaler(aspect, body[aspect]); 289 | scale(deduplicated); 290 | } 291 | const composedTable: ComposedTable = { 292 | body, 293 | baked, 294 | [deduplicationComponent as "head"]: deduplicated, 295 | [opposite as "indexes"]: 296 | blocksToStack && blockInPlaceStacker(blocksToStack), 297 | }; 298 | return composedTable; 299 | }; 300 | } 301 | -------------------------------------------------------------------------------- /packages/core/src/json-to-table/json-to-table.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { blockToASCII } from "../block-to-ascii/index.js"; 4 | import type { JSONPrimitive } from "../lib/json.js"; 5 | 6 | import { makeTableFactory } from "./json-to-table.js"; 7 | import { makeTableInPlaceBaker } from './table'; 8 | 9 | import simpleHeadersDuplication from "./__fixtures__/simple-headers-duplication.json"; 10 | import simpleIndexesDeduplication from "./__fixtures__/simple-indexes-deduplication.json"; 11 | import parsingError from "./__fixtures__/parsing-error.json"; 12 | import differentHeaders from "./__fixtures__/different-headers.json"; 13 | import uniqueHeaders from "./__fixtures__/uniq-headers.json"; 14 | import wrongSizes from "./__fixtures__/wrong-sizes.json"; 15 | import emptyArrays from "./__fixtures__/empty-arrays.json" 16 | 17 | describe("makeTableFactory", () => { 18 | const cornerCellValue = "№"; 19 | const factory = makeTableFactory({ cornerCellValue }); 20 | const bake = makeTableInPlaceBaker({ 21 | cornerCellValue, 22 | head: true, 23 | indexes: true, 24 | }); 25 | 26 | it("Should create table for primitives", () => { 27 | const data = [false, 12345, "abcde"]; 28 | for (const value of data) { 29 | const table = factory(value); 30 | const ascii = blockToASCII(bake(table)); 31 | expect(`\n${ascii}`).toBe(` 32 | +-------+ 33 | | ${value} | 34 | +-------+`); 35 | } 36 | }); 37 | 38 | it("Should create table for objects", () => { 39 | const data = { 40 | a: 1, 41 | b: 2, 42 | c: { aa: 11, bb: 22 }, 43 | }; 44 | const table = factory(data); 45 | const ascii = blockToASCII(bake(table)); 46 | expect(`\n${ascii}\n`).toBe(` 47 | +---+---+---------+ 48 | | a | b | c | 49 | +---+---+----+----+ 50 | | | | aa | bb | 51 | | 1 | 2 +----+----+ 52 | | | | 11 | 22 | 53 | +---+---+----+----+ 54 | `); 55 | }); 56 | 57 | it("Should create table for arrays", () => { 58 | const data = [1, 2, [11, 22]]; 59 | const table = factory(data); 60 | const ascii = blockToASCII(bake(table)); 61 | expect(`\n${ascii}\n`).toBe(` 62 | +---+--------+ 63 | | 1 | 1 | 64 | +---+--------+ 65 | | 2 | 2 | 66 | +---+---+----+ 67 | | | 1 | 11 | 68 | | 3 +---+----+ 69 | | | 2 | 22 | 70 | +---+---+----+ 71 | `); 72 | }); 73 | 74 | it("Should create table for arrays with indexes collapse", () => { 75 | const factory = makeTableFactory({ 76 | cornerCellValue, 77 | collapseIndexes: true, 78 | }); 79 | const data = [ 80 | [1, 2], 81 | [11, 22], 82 | ]; 83 | const table = factory(data); 84 | const ascii = blockToASCII(bake(table)); 85 | expect(`\n${ascii}\n`).toBe(` 86 | +-----+----+ 87 | | 1.1 | 1 | 88 | +-----+----+ 89 | | 1.2 | 2 | 90 | +-----+----+ 91 | | 2.1 | 11 | 92 | +-----+----+ 93 | | 2.2 | 22 | 94 | +-----+----+ 95 | `); 96 | }); 97 | 98 | it("Should deduplicate table headers", () => { 99 | const table = factory(simpleHeadersDuplication); 100 | const ascii = blockToASCII(bake(table)); 101 | expect(`\n${ascii}\n`).toBe(` 102 | +---+---+---+---+ 103 | | № | a | b | c | 104 | +---+---+---+---+ 105 | | 1 | 1 | 2 | 3 | 106 | +---+---+---+---+ 107 | | 2 | 4 | 5 | 6 | 108 | +---+---+---+---+ 109 | | 3 | 7 | 8 | 9 | 110 | +---+---+---+---+ 111 | `); 112 | }); 113 | 114 | it("Should deduplicate table indexes", () => { 115 | const table = factory(simpleIndexesDeduplication); 116 | const ascii = blockToASCII(bake(table)); 117 | expect(`\n${ascii}\n`).toBe(` 118 | +---+---+---+---+ 119 | | № | a | b | c | 120 | +---+---+---+---+ 121 | | 1 | 1 | 2 | 3 | 122 | +---+---+---+---+ 123 | | 2 | 4 | 5 | 6 | 124 | +---+---+---+---+ 125 | | 3 | 7 | 8 | 9 | 126 | +---+---+---+---+ 127 | `); 128 | }); 129 | 130 | it("Should combine simple values should not affect objects values", () => { 131 | const factory = makeTableFactory({ 132 | cornerCellValue, 133 | joinPrimitiveArrayValues: true, 134 | }); 135 | const table = factory(parsingError); 136 | const ascii = blockToASCII(bake(table)); 137 | expect(`\n${ascii}\n`).toBe(` 138 | +---+-----------------------------------+ 139 | | | weather | 140 | | № +------+-------+-------------+------+ 141 | | | id | main | description | icon | 142 | +---+------+-------+-------------+------+ 143 | | 1 | 800 | Clear | clear sky | 01n | 144 | +---+------+-------+-------------+------+ 145 | `); 146 | }); 147 | 148 | it("Should not deduplicate objects with different headers", () => { 149 | const table = factory(differentHeaders as any); 150 | const ascii = blockToASCII(bake(table)); 151 | expect(`\n${ascii}\n`).toBe(` 152 | +---+------------------------------+---------------------+ 153 | | | character_id | item_id | 154 | | 1 +------------------------------+---------------------+ 155 | | | 5428010618020694593 | 95 | 156 | +---+---------------------+--------+-------+-------------+ 157 | | | character_id | item_id | stack_count | 158 | | 2 +---------------------+----------------+-------------+ 159 | | | 5428010618020694593 | 101 | 4 | 160 | +---+---------------------+----------------+-------------+ 161 | `); 162 | }); 163 | 164 | it("Should work with unique headers", () => { 165 | const table = factory(uniqueHeaders as any); 166 | const ascii = blockToASCII(bake(table)); 167 | expect(`\n${ascii}\n`).toBe(` 168 | +---+-----------------------------+------+------+----------+--------+-------------+ 169 | | | description | in | name | required | type | uniqueItems | 170 | | 1 +-----------------------------+------+------+----------+--------+-------------+ 171 | | | name of the ComponentStatus | path | name | true | string | true | 172 | +---+-----------------------------+------+------+----------+--------+-------------+ 173 | | | $ref | 174 | | 2 +-----------------------------------------------------------------------------+ 175 | | | #/parameters/pretty-tJGM1-ng | 176 | +---+-----------------------------------------------------------------------------+ 177 | `); 178 | }); 179 | 180 | it("Should create correct table", () => { 181 | const table = factory(wrongSizes as any); 182 | const ascii = blockToASCII(bake(table)); 183 | expect(`\n${ascii}\n`).toBe(` 184 | +---+---------------+---------------+-----------------------------------------+ 185 | | № | options | pluginVersion | targets | 186 | +---+---------------+---------------+-------+------------------------+--------+ 187 | | | reduceOptions | | | | | 188 | | | | | № | expr | format | 189 | | +---------------+ | | | | 190 | | | | | | | | 191 | | 1 | values | 7.3.1 +-------+------------------------+--------+ 192 | | | | | | | | 193 | | +---------------+ | 1 | loki_build_info | table | 194 | | | false | | | | | 195 | | | | | | | | 196 | +---+---------------+---------------+-------+------------------------+--------+ 197 | | | reduceOptions | | | | 198 | | | | | № | expr | 199 | | +---------------+ | | | 200 | | | | | | | 201 | | 2 | values | 7.3.1 +-------+---------------------------------+ 202 | | | | | | | 203 | | +---------------+ | 1 | sum(log_messages_total) | 204 | | | false | | | | 205 | | | | | | | 206 | +---+---------------+---------------+-------+---------------------------------+ 207 | `); 208 | }); 209 | 210 | it('Should deduplicate equal headers with different sizes', () => { 211 | const data = [ 212 | {a:1, b:2, c: 3}, 213 | {a:1, b:2, c: {d: 4, e: 5}} 214 | ] 215 | const table = factory(data); 216 | const ascii = blockToASCII(bake(table)); 217 | expect(`\n${ascii}\n`).toBe(` 218 | +---+---+---+-------+ 219 | | № | a | b | c | 220 | +---+---+---+-------+ 221 | | 1 | 1 | 2 | 3 | 222 | +---+---+---+---+---+ 223 | | | | | d | e | 224 | | 2 | 1 | 2 +---+---+ 225 | | | | | 4 | 5 | 226 | +---+---+---+---+---+ 227 | `); 228 | }) 229 | // The original problem was in the modification of global `EMPTY` table 230 | it('Should handle empty arrays', () => { 231 | const table = factory(emptyArrays as any); 232 | const ascii = blockToASCII(bake(table)); 233 | expect(`\n${ascii}\n`).toBe(` 234 | +---+------------------------------------------------------------------------------+ 235 | | | tasks | 236 | | № +---------------------------+---+----------------------------------------------+ 237 | | | n | d | a | 238 | +---+---+-----------------------+---+----------------------+-----------------------+ 239 | | 1 | 1 | UspYpi-8NwmZZR7FJprSb | | 1 | aCx8zMrOjqW6K55TMokHD | 240 | +---+---+-----------------------+---+----------------------+-----------------------+ 241 | | 2 | | | 1 | gwT5xfbxgkPCq_VDyoBO3 | 242 | +---+---+-----------------------+---+----------------------------------------------+ 243 | `); 244 | }) 245 | }); 246 | -------------------------------------------------------------------------------- /packages/core/src/block/block.ts: -------------------------------------------------------------------------------- 1 | import { lcm, max } from "../lib/math.js"; 2 | import { array } from "../lib/array.js"; 3 | 4 | import type { 5 | Block, 6 | BlockCompositor, 7 | BlockSizeAspect, 8 | Cell, 9 | ProportionalResizeGuard, 10 | Cells, 11 | RowsScaler, 12 | Rows, 13 | BlockTransformInPlace, 14 | } from "../json-table.js"; 15 | 16 | import { 17 | scaleRowsHorizontallyInPlace, 18 | scaleRowsVerticallyInPlace, 19 | } from "./row.js"; 20 | 21 | export interface AreBlocksEqualOptions { 22 | blocks: Block[]; 23 | width: number; 24 | height: number; 25 | widthIsLcm?: boolean; 26 | heightIsLcm?: boolean; 27 | } 28 | 29 | export function areBlocksEqual({ 30 | blocks, 31 | width, 32 | heightIsLcm = true, 33 | height, 34 | widthIsLcm = true, 35 | }: AreBlocksEqualOptions): boolean { 36 | const blocksRows = blocks.map((b) => { 37 | const wMultiplier = widthIsLcm ? width / b.width : 1; 38 | const hMultiplier = heightIsLcm ? height / b.height : 1; 39 | const { rows, indexes } = b.data; 40 | const newRows = array(height, () => new Array>(width)); 41 | for (let i = 0; i < rows.length; i++) { 42 | const index = indexes[i]!; 43 | const { cells, columns } = rows[i]!; 44 | for (let j = 0; j < cells.length; j++) { 45 | const cell = cells[j]!; 46 | const row = index * hMultiplier; 47 | let rowEnd = row + cell.height * hMultiplier; 48 | if (!heightIsLcm && rowEnd === b.height && rowEnd < height) { 49 | rowEnd = height; 50 | } 51 | const col = columns[j]! * wMultiplier; 52 | let colEnd = col + cell.width * wMultiplier; 53 | if (!widthIsLcm && colEnd === b.width && colEnd < width) { 54 | colEnd = width; 55 | } 56 | for (let k = row; k < rowEnd; k++) { 57 | const newRow = newRows[k]!; 58 | for (let l = col; l < colEnd; l++) { 59 | newRow[l] = cell; 60 | } 61 | } 62 | } 63 | } 64 | return newRows; 65 | }); 66 | // Loop over rows 67 | for (let i = 0; i < height; i++) { 68 | const firstBlockRow = blocksRows[0]![i]!; 69 | // Loop over cells 70 | for (let j = 0; j < width; j++) { 71 | const firstBlockCell = firstBlockRow[j]!; 72 | // Loop over other blocks 73 | for (let k = 1; k < blocks.length; k++) { 74 | const cell = blocksRows[k]![i]![j]; 75 | if (!cell || firstBlockCell.value !== cell.value) { 76 | return false; 77 | } 78 | } 79 | } 80 | i++; 81 | } 82 | return true; 83 | } 84 | 85 | function applyResizeInPlace( 86 | { rows }: Rows, 87 | toResize: Map>, 88 | sizeAspect: BlockSizeAspect 89 | ): void { 90 | for (const [rowId, cells] of toResize) { 91 | const rowCells = rows[rowId]!.cells; 92 | for (const [cellId, diff] of cells) { 93 | rowCells[cellId]![sizeAspect] += diff; 94 | } 95 | } 96 | } 97 | 98 | export function stretchCellsToBottomInPlace({ 99 | data, 100 | height, 101 | width, 102 | }: Block): void { 103 | // TODO: Calculate yShift by row index and cell height 104 | const yShift = array(width, () => 0); 105 | const bottomPositions = new Array< 106 | | { 107 | cell: Cell; 108 | rowIndex: number; 109 | colIndex: number; 110 | } 111 | | null 112 | | undefined 113 | >(width); 114 | const { rows } = data; 115 | for (let i = 0; i < rows.length; i++) { 116 | const { cells, columns } = rows[i]!; 117 | for (let j = 0; j < cells.length; j++) { 118 | const cell = cells[j]!; 119 | const x = columns[j]!; 120 | yShift[x]! += cell.height; 121 | bottomPositions[x] = { cell, rowIndex: i, colIndex: j }; 122 | for (let k = 1; k < cell.width; k++) { 123 | yShift[x + k]! += cell.height; 124 | bottomPositions[x + k] = null; 125 | } 126 | } 127 | } 128 | // rowId: { cellId: diff } 129 | const toResize = new Map>(); 130 | for (let i = 0; i < width; i++) { 131 | const position = bottomPositions[i]; 132 | if (!position) { 133 | continue; 134 | } 135 | const diff = height - yShift[i]!; 136 | if (diff <= 0) { 137 | continue; 138 | } 139 | const cells = toResize.get(position.rowIndex) || new Map(); 140 | toResize.set(position.rowIndex, cells.set(position.colIndex, diff)); 141 | } 142 | applyResizeInPlace(data, toResize, "height"); 143 | } 144 | 145 | export function stretchCellsToRightInPlace({ 146 | data, 147 | height, 148 | width, 149 | }: Block) { 150 | const rightPositions = new Array< 151 | | { 152 | cell: Cell; 153 | indexInRow: number; 154 | xTopRightCorner: number; 155 | } 156 | | undefined 157 | >(height); 158 | const { rows, indexes } = data; 159 | for (let i = 0; i < rows.length; i++) { 160 | const { cells, columns } = rows[i]!; 161 | if (cells.length === 0) { 162 | continue; 163 | } 164 | const indexInRow = cells.length - 1; 165 | const cell = cells[indexInRow]!; 166 | const xTopRightCorner = columns[indexInRow]! + cell.width; 167 | const point = { 168 | cell, 169 | indexInRow, 170 | xTopRightCorner, 171 | }; 172 | const index = indexes[i]!; 173 | for (let j = index; j < index + cell.height; j++) { 174 | const rp = rightPositions[j]; 175 | if (!rp || xTopRightCorner > rp.xTopRightCorner) { 176 | rightPositions[j] = point; 177 | } 178 | } 179 | } 180 | // TODO: this algorithm can be implemented without `set` of cells 181 | const addedToResize = new Set>(); 182 | const toResize = new Map>(); 183 | for (let i = 0; i < rows.length; i++) { 184 | const index = indexes[i]!; 185 | const position = rightPositions[index]; 186 | if (!position || addedToResize.has(position.cell)) { 187 | continue; 188 | } 189 | addedToResize.add(position.cell); 190 | const diff = width - position.xTopRightCorner; 191 | if (diff <= 0) { 192 | continue; 193 | } 194 | const cells = toResize.get(i) || new Map(); 195 | toResize.set(i, cells.set(position.indexInRow, diff)); 196 | } 197 | applyResizeInPlace(data, toResize, "width"); 198 | } 199 | 200 | const SIZE_ASPECT_TO_ROWS_IN_PLACE_SCALER: Record< 201 | BlockSizeAspect, 202 | RowsScaler 203 | > = { 204 | width: scaleRowsHorizontallyInPlace, 205 | height: scaleRowsVerticallyInPlace, 206 | }; 207 | 208 | const SIZE_ASPECT_TO_CELLS_IN_PLACE_STRETCHER: Record< 209 | BlockSizeAspect, 210 | BlockTransformInPlace 211 | > = { 212 | width: stretchCellsToRightInPlace, 213 | height: stretchCellsToBottomInPlace, 214 | }; 215 | 216 | export function makeBlockInPlaceScaler( 217 | sizeAspect: BlockSizeAspect, 218 | finalSize: number 219 | ): BlockTransformInPlace { 220 | const scaleRowsInPlace = SIZE_ASPECT_TO_ROWS_IN_PLACE_SCALER[sizeAspect]; 221 | const stretchCellsInPlace = 222 | SIZE_ASPECT_TO_CELLS_IN_PLACE_STRETCHER[sizeAspect]; 223 | return (table) => { 224 | const multiplier = Math.floor(finalSize / table[sizeAspect]); 225 | if (multiplier > 1) { 226 | scaleRowsInPlace(table.data, multiplier); 227 | } 228 | let oldSize = table[sizeAspect]; 229 | table[sizeAspect] = finalSize; 230 | if (finalSize - oldSize * multiplier > 0) { 231 | stretchCellsInPlace(table); 232 | } 233 | }; 234 | } 235 | 236 | export function mergeBlocksVertically( 237 | blocks: Block[], 238 | width: number 239 | ): Block { 240 | // TODO: first block can be used as accumulator 241 | const rows = new Array>(0); 242 | const indexes = new Array(0); 243 | let index = 0; 244 | for (let i = 0; i < blocks.length; i++) { 245 | const { data, height } = blocks[i]!; 246 | for (let j = 0; j < data.rows.length; j++) { 247 | indexes.push(index + data.indexes[j]!); 248 | rows.push(data.rows[j]!); 249 | } 250 | index += height; 251 | } 252 | return { 253 | width, 254 | height: index, 255 | data: { rows, indexes }, 256 | }; 257 | } 258 | 259 | export function compressRawRowsInPlaceAndMakeIndexes( 260 | rows: Cells[] 261 | ): number[] { 262 | const indexes = new Array(rows.length); 263 | let shift = 0; 264 | for (let i = 0; i < rows.length; i++) { 265 | if (rows[i]!.cells.length === 0) { 266 | shift++; 267 | continue; 268 | } 269 | rows[i - shift] = rows[i]!; 270 | indexes[i - shift] = i; 271 | } 272 | rows.length -= shift; 273 | indexes.length -= shift; 274 | return indexes; 275 | } 276 | 277 | export function mergeBlocksHorizontally( 278 | blocks: Block[], 279 | height: number 280 | ): Block { 281 | const newRows = array(height, (): Cells => ({ cells: [], columns: [] })); 282 | let width = 0; 283 | for (let i = 0; i < blocks.length; i++) { 284 | const { 285 | data: { rows, indexes }, 286 | width: blockWidth, 287 | } = blocks[i]!; 288 | for (let j = 0; j < rows.length; j++) { 289 | const { cells, columns } = rows[j]!; 290 | const row = newRows[indexes[j]!]!; 291 | for (let k = 0; k < cells.length; k++) { 292 | row.cells.push(cells[k]!); 293 | row.columns.push(columns[k]! + width); 294 | } 295 | } 296 | width += blockWidth; 297 | } 298 | // Rows created locally, so no mutations 299 | const indexes = compressRawRowsInPlaceAndMakeIndexes(newRows); 300 | return { 301 | width, 302 | height, 303 | data: { 304 | rows: newRows, 305 | indexes, 306 | }, 307 | }; 308 | } 309 | 310 | // TODO: combine into one function `makeBlockStacker` 311 | export function makeVerticalBlockInPlaceStacker( 312 | isProportionalResize: ProportionalResizeGuard 313 | ): BlockCompositor { 314 | return function stackBlocksVertically(blocks) { 315 | let lcmWidth = blocks[0]!.width; 316 | let maxWidth = lcmWidth; 317 | for (let i = 1; i < blocks.length; i++) { 318 | const block = blocks[i]!; 319 | lcmWidth = lcm(lcmWidth, block.width); 320 | maxWidth = max(maxWidth, block.width); 321 | } 322 | const width = isProportionalResize(lcmWidth, maxWidth) 323 | ? lcmWidth 324 | : maxWidth; 325 | const scale = makeBlockInPlaceScaler("width", width); 326 | for (let i = 0; i < blocks.length; i++) { 327 | scale(blocks[i]!); 328 | } 329 | return mergeBlocksVertically(blocks, width); 330 | }; 331 | } 332 | export function makeHorizontalBlockInPlaceStacker( 333 | isProportionalResize: ProportionalResizeGuard 334 | ): BlockCompositor { 335 | return (blocks) => { 336 | let lcmHeight = blocks[0]!.height; 337 | let maxHeight = lcmHeight; 338 | for (let i = 1; i < blocks.length; i++) { 339 | lcmHeight = lcm(lcmHeight, blocks[i]!.height); 340 | maxHeight = max(maxHeight, blocks[i]!.height); 341 | } 342 | const height = isProportionalResize(lcmHeight, maxHeight) 343 | ? lcmHeight 344 | : maxHeight; 345 | const scale = makeBlockInPlaceScaler("height", height); 346 | for (let i = 0; i < blocks.length; i++) { 347 | scale(blocks[i]!); 348 | } 349 | return mergeBlocksHorizontally(blocks, height); 350 | }; 351 | } 352 | -------------------------------------------------------------------------------- /packages/core/src/block/block.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | 3 | import { type Block, CellType } from "../json-table.js"; 4 | import { 5 | stretchCellsToBottomInPlace, 6 | stretchCellsToRightInPlace, 7 | makeBlockInPlaceScaler, 8 | areBlocksEqual, 9 | } from "./block.js"; 10 | 11 | describe("stretchCellsToBottom", () => { 12 | it("Should work with shifted bottom cell", () => { 13 | const data: Block = { 14 | height: 2, 15 | width: 3, 16 | data: { 17 | rows: [ 18 | { 19 | cells: [ 20 | { 21 | height: 1, 22 | width: 1, 23 | value: 1, 24 | type: CellType.Value, 25 | }, 26 | { 27 | height: 1, 28 | width: 1, 29 | value: 2, 30 | type: CellType.Value, 31 | }, 32 | { 33 | height: 1, 34 | width: 1, 35 | value: 3, 36 | type: CellType.Value, 37 | }, 38 | ], 39 | columns: [0, 1, 2], 40 | }, 41 | { 42 | cells: [ 43 | { 44 | height: 1, 45 | width: 1, 46 | value: 4, 47 | type: CellType.Value, 48 | }, 49 | ], 50 | columns: [1], 51 | }, 52 | ], 53 | indexes: [0, 1], 54 | }, 55 | }; 56 | const expected: Block = { 57 | height: 2, 58 | width: 3, 59 | data: { 60 | rows: [ 61 | { 62 | cells: [ 63 | { 64 | height: 2, 65 | width: 1, 66 | value: 1, 67 | type: CellType.Value, 68 | }, 69 | { 70 | height: 1, 71 | width: 1, 72 | value: 2, 73 | type: CellType.Value, 74 | }, 75 | { 76 | height: 2, 77 | width: 1, 78 | value: 3, 79 | type: CellType.Value, 80 | }, 81 | ], 82 | columns: [0, 1, 2], 83 | }, 84 | { 85 | cells: [ 86 | { 87 | height: 1, 88 | width: 1, 89 | value: 4, 90 | type: CellType.Value, 91 | }, 92 | ], 93 | columns: [1], 94 | }, 95 | ], 96 | indexes: [0, 1], 97 | }, 98 | }; 99 | stretchCellsToBottomInPlace(data); 100 | expect(data).toEqual(expected); 101 | }); 102 | }); 103 | 104 | describe("stretchCellsToRight", () => { 105 | it("Should work with shifted right cell", () => { 106 | const data: Block = { 107 | height: 3, 108 | width: 2, 109 | data: { 110 | rows: [ 111 | { 112 | cells: [ 113 | { 114 | height: 1, 115 | width: 1, 116 | value: 1, 117 | type: CellType.Value, 118 | }, 119 | ], 120 | columns: [0], 121 | }, 122 | { 123 | cells: [ 124 | { 125 | height: 1, 126 | width: 1, 127 | value: 2, 128 | type: CellType.Value, 129 | }, 130 | { 131 | height: 1, 132 | width: 1, 133 | value: 4, 134 | type: CellType.Value, 135 | }, 136 | ], 137 | columns: [0, 1], 138 | }, 139 | { 140 | cells: [ 141 | { 142 | height: 1, 143 | width: 1, 144 | value: 3, 145 | type: CellType.Value, 146 | }, 147 | ], 148 | columns: [0], 149 | }, 150 | ], 151 | indexes: [0, 1, 2], 152 | }, 153 | }; 154 | const expected: Block = { 155 | height: 3, 156 | width: 2, 157 | data: { 158 | rows: [ 159 | { 160 | cells: [ 161 | { 162 | height: 1, 163 | width: 2, 164 | value: 1, 165 | type: CellType.Value, 166 | }, 167 | ], 168 | columns: [0], 169 | }, 170 | { 171 | cells: [ 172 | { 173 | height: 1, 174 | width: 1, 175 | value: 2, 176 | type: CellType.Value, 177 | }, 178 | { 179 | height: 1, 180 | width: 1, 181 | value: 4, 182 | type: CellType.Value, 183 | }, 184 | ], 185 | columns: [0, 1], 186 | }, 187 | { 188 | cells: [ 189 | { 190 | height: 1, 191 | width: 2, 192 | value: 3, 193 | type: CellType.Value, 194 | }, 195 | ], 196 | columns: [0], 197 | }, 198 | ], 199 | indexes: [0, 1, 2], 200 | }, 201 | }; 202 | stretchCellsToRightInPlace(data); 203 | expect(data).toEqual(expected); 204 | }); 205 | }); 206 | 207 | describe("makeBlockScaler", () => { 208 | it("Should scale correctly height and fill empty cells", () => { 209 | const scale = makeBlockInPlaceScaler("height", 5); 210 | const data: Block = { 211 | height: 2, 212 | width: 2, 213 | data: { 214 | rows: [ 215 | { 216 | cells: [ 217 | { 218 | height: 1, 219 | width: 1, 220 | value: 1, 221 | type: CellType.Value, 222 | }, 223 | { 224 | height: 1, 225 | width: 1, 226 | value: 2, 227 | type: CellType.Value, 228 | }, 229 | ], 230 | columns: [0, 1], 231 | }, 232 | { 233 | cells: [ 234 | { 235 | height: 1, 236 | width: 1, 237 | value: 3, 238 | type: CellType.Value, 239 | }, 240 | { 241 | height: 1, 242 | width: 1, 243 | value: 4, 244 | type: CellType.Value, 245 | }, 246 | ], 247 | columns: [0, 1], 248 | }, 249 | ], 250 | indexes: [0, 1], 251 | }, 252 | }; 253 | const expected: Block = { 254 | height: 5, 255 | width: 2, 256 | data: { 257 | rows: [ 258 | { 259 | cells: [ 260 | { 261 | height: 2, 262 | width: 1, 263 | value: 1, 264 | type: CellType.Value, 265 | }, 266 | { 267 | height: 2, 268 | width: 1, 269 | value: 2, 270 | type: CellType.Value, 271 | }, 272 | ], 273 | columns: [0, 1], 274 | }, 275 | { 276 | cells: [ 277 | { 278 | height: 3, 279 | width: 1, 280 | value: 3, 281 | type: CellType.Value, 282 | }, 283 | { 284 | height: 3, 285 | width: 1, 286 | value: 4, 287 | type: CellType.Value, 288 | }, 289 | ], 290 | columns: [0, 1], 291 | }, 292 | ], 293 | indexes: [0, 2], 294 | }, 295 | }; 296 | scale(data); 297 | expect(data).toEqual(expected); 298 | }); 299 | }); 300 | 301 | describe("areBlocksEqual", () => { 302 | it("Should return true for equal blocks", () => { 303 | expect( 304 | areBlocksEqual({ 305 | blocks: [ 306 | { 307 | height: 1, 308 | width: 2, 309 | data: { 310 | rows: [ 311 | { 312 | cells: [ 313 | { 314 | height: 1, 315 | width: 1, 316 | value: 1, 317 | type: CellType.Value, 318 | }, 319 | { 320 | height: 1, 321 | width: 1, 322 | value: 2, 323 | type: CellType.Value, 324 | }, 325 | ], 326 | columns: [0, 1], 327 | }, 328 | ], 329 | indexes: [0], 330 | }, 331 | }, 332 | { 333 | height: 2, 334 | width: 4, 335 | data: { 336 | rows: [ 337 | { 338 | cells: [ 339 | { 340 | height: 2, 341 | width: 2, 342 | value: 1, 343 | type: CellType.Value, 344 | }, 345 | { 346 | height: 2, 347 | width: 2, 348 | value: 2, 349 | type: CellType.Value, 350 | }, 351 | ], 352 | columns: [0, 2], 353 | }, 354 | ], 355 | indexes: [0], 356 | }, 357 | }, 358 | ], 359 | height: 2, 360 | width: 4, 361 | }) 362 | ).toBe(true); 363 | }); 364 | 365 | it("Should return false for unequal blocks", () => { 366 | expect( 367 | areBlocksEqual({ 368 | blocks: [ 369 | { 370 | height: 1, 371 | width: 2, 372 | data: { 373 | rows: [ 374 | { 375 | cells: [ 376 | { 377 | height: 1, 378 | width: 1, 379 | value: 1, 380 | type: CellType.Value, 381 | }, 382 | { 383 | height: 1, 384 | width: 1, 385 | value: 2, 386 | type: CellType.Value, 387 | }, 388 | ], 389 | columns: [0, 1], 390 | }, 391 | ], 392 | indexes: [0], 393 | }, 394 | }, 395 | { 396 | height: 1, 397 | width: 3, 398 | data: { 399 | rows: [ 400 | { 401 | cells: [ 402 | { 403 | height: 1, 404 | width: 1, 405 | value: 1, 406 | type: CellType.Value, 407 | }, 408 | { 409 | height: 1, 410 | width: 1, 411 | value: 2, 412 | type: CellType.Value, 413 | }, 414 | { 415 | height: 1, 416 | width: 1, 417 | value: 3, 418 | type: CellType.Value, 419 | }, 420 | ], 421 | columns: [0, 1, 2], 422 | }, 423 | ], 424 | indexes: [0], 425 | }, 426 | }, 427 | ], 428 | width: 6, 429 | height: 1, 430 | }) 431 | ).toBe(false); 432 | }); 433 | 434 | it("Should return true for equal blocks with different sizes", () => { 435 | expect( 436 | areBlocksEqual({ 437 | blocks: [ 438 | { 439 | height: 1, 440 | width: 2, 441 | data: { 442 | rows: [ 443 | { 444 | cells: [ 445 | { 446 | height: 1, 447 | width: 1, 448 | value: 1, 449 | type: CellType.Value, 450 | }, 451 | { 452 | height: 1, 453 | width: 1, 454 | value: 2, 455 | type: CellType.Value, 456 | }, 457 | ], 458 | columns: [0, 1], 459 | }, 460 | ], 461 | indexes: [0], 462 | }, 463 | }, 464 | { 465 | height: 1, 466 | width: 3, 467 | data: { 468 | rows: [ 469 | { 470 | cells: [ 471 | { 472 | height: 1, 473 | width: 1, 474 | value: 1, 475 | type: CellType.Value, 476 | }, 477 | { 478 | height: 1, 479 | width: 2, 480 | value: 2, 481 | type: CellType.Value, 482 | }, 483 | ], 484 | columns: [0, 1], 485 | }, 486 | ], 487 | indexes: [0], 488 | }, 489 | }, 490 | ], 491 | width: 3, 492 | widthIsLcm: false, 493 | height: 1, 494 | }) 495 | ).toBe(true); 496 | }); 497 | }); 498 | --------------------------------------------------------------------------------