first commit

This commit is contained in:
Sakurai Ryota 2024-04-21 14:39:49 +09:00
commit 9ca483538b
23 changed files with 2701 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
public/dist

7
README.md Normal file
View File

@ -0,0 +1,7 @@
InlineDocument
===
## Build
Run command:
`$ npm run build`

1565
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

13
package.json Normal file
View File

@ -0,0 +1,13 @@
{
"scripts": {
"build": "webpack",
"watch": "webpack -w"
},
"devDependencies": {
"ts-loader": "^9.5.1",
"typescript": "^5.4.5",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4"
},
"private": true
}

124
public/test.idoc Normal file
View File

@ -0,0 +1,124 @@
{
"documentInfo": {
"title": "Test",
"author": "sakurai"
},
"pages": [
{
"pageNumber": 1,
"layerIndex": 1,
"width": 1280,
"height": 720,
"drawOperators": [
{
"drawFunction": 2,
"drawParameter": ["#000000"]
},
{
"drawFunction": 18,
"drawParameter": [0,0,1280,720]
},
{
"drawFunction": 2,
"drawParameter": ["rgba(255, 0, 0, 0.3)"]
},
{
"drawFunction": 18,
"drawParameter": [10,10,100,100]
},
{
"drawFunction": 2,
"drawParameter": ["#FFFFFF"]
},
{
"drawFunction": 24,
"drawParameter": ["monospace", 28, ""]
},
{
"drawFunction": 22,
"drawParameter": ["こんにちは、世界", 30, 30]
},
{
"drawFunction": 22,
"drawParameter": ["これは、InlineDocumentで文字列を出力するサンプルです。", 30, 60]
},
{
"drawFunction": 4,
"drawParameter": []
},
{
"drawFunction": 14,
"drawParameter": [100, 100]
},
{
"drawFunction": 13,
"drawParameter": [200, 200]
},
{
"drawFunction": 13,
"drawParameter": [250, 150]
},
{
"drawFunction": 10,
"drawParameter": [300, 300, 500, 500, 1600, 600]
},
{
"drawFunction": 5,
"drawParameter": []
},
{
"drawFunction": 6,
"drawParameter": []
},
{
"drawFunction": 3,
"drawParameter": ["rgba(255, 0, 0, 1)"]
},
{
"drawFunction": 7,
"drawParameter": []
}
]
},
{
"pageNumber": 2,
"layerIndex": 1,
"width": 210,
"height": 270,
"drawOperators": [
{
"drawFunction": 17,
"drawParameter": [0,0,210,270]
},
{
"drawFunction": 2,
"drawParameter": ["rgba(255, 0, 0, 0.3)"]
},
{
"drawFunction": 18,
"drawParameter": [10,10,100,100]
}
]
},
{
"pageNumber": 3,
"layerIndex": 1,
"width": 210,
"height": 270,
"drawOperators": [
{
"drawFunction": 17,
"drawParameter": [0,0,210,270]
},
{
"drawFunction": 2,
"drawParameter": ["rgba(255, 0, 0, 0.3)"]
},
{
"drawFunction": 18,
"drawParameter": [10,10,100,100]
}
]
}
]
}

79
public/viewer.css Normal file
View File

@ -0,0 +1,79 @@
html, body {
margin: 0;
padding: 0;
width: 100dvw;
height: 100dvh;
overflow: hidden;
}
.container {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: auto;
overflow: hidden;
position: relative;
}
.viewer-navigation {
position: fixed;
top: 0;
left: 0;
right: 0;
border-bottom: 1px solid #cccccc;
height: 50px;
background-color: #ffffff;
display: flex;
}
.viewer-navigation-group {
margin: 0.5em;
width: auto;
}
.viewer-navigation-group .button {
font-size: 0.8em;
font-family: Arial, Helvetica, sans-serif;
max-width: 200px;
border: 1px solid #aaaaaa;
background: #eeeeee;
padding: 5px 7px;
}
.viewer-navigation-group .button:active {
background-color: #aaaaaa;
opacity: 0.8;
box-shadow: 1px 1px 1px #aaaaaa;
}
.viewer-navigation-group .button:hover {
background-color: #aaaaaa;
}
.viewer-container {
overflow: auto;
width: 100%;
height: 100%;
}
.viewer {
text-align: center;
padding-top: 50px;
}
.viewer .page {
border: 1px solid #cccccc;
margin: 1em auto;
}
#openFile {
display: none;
}
#scaleOption {
width: 100px;
height: 25px;
background: #ffffff;
border: 1px solid #dddddd;
margin: auto;
}

29
public/viewer.html Normal file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>InlineDocument viewer</title>
<link rel="stylesheet" href="./viewer.css" />
</head>
<body>
<div class="container">
<div class="viewer-navigation">
<div class="viewer-navigation-group">
<button type="button" class="button" id="openFileButton">表示</button>
</div>
<div class="viewer-navigation-group">
<select id="scaleOption">
<option value="" id="customScale">100%</option>
</select>
</div>
</div>
<div class="viewer-container">
<div id="viewer" class="viewer"></div>
</div>
</div>
<input type="file" id="openFile" />
<script defer src="./dist/inlinedocument.js"></script>
<script defer src="./dist/viewer.js"></script>
</body>
</html>

View File

@ -0,0 +1,34 @@
import { DocumentInfo } from "./document_info";
import { PageInfo } from "./page_info";
class InlineDocument {
#docInfo: DocumentInfo;
#pages: PageInfo[];
constructor(
docInfo: DocumentInfo,
pages: PageInfo[]
) {
this.#docInfo = docInfo;
this.#pages = pages;
}
get documentInfo() {
return this.#docInfo;
}
get pages() {
return this.#pages;
}
toJSON() {
return {
documentInfo: this.#docInfo,
pages: this.#pages,
};
}
}
export {
InlineDocument,
}

View File

@ -0,0 +1,31 @@
class DocumentInfo {
#title: string;
#author: string;
constructor(
title: string,
author: string
) {
this.#title = title;
this.#author = author;
}
get title() {
return this.#title;
}
get author() {
return this.#author;
}
toJSON() {
return {
title: this.title,
author: this.author,
}
}
}
export {
DocumentInfo,
}

View File

@ -0,0 +1,65 @@
const DrawFunction = Object.freeze({
/** Initialize Page for drawing. */
Init: 0,
transform: 1,
fillStyle: 2,
strokeStyle: 3,
beginPath: 4,
closePath: 5,
fill: 6,
stroke: 7,
arc: 8,
arcTo: 9,
bezierCurveTo: 10,
quadraticCurveTo: 11,
ellipse: 12,
lineTo: 13,
moveTo: 14,
rect: 15,
roundRect: 16,
clearRect: 17,
fillRect: 18,
strokeRect: 19,
save: 20,
restore: 21,
fillText: 22,
strokeText: 23,
setFont: 24,
rotate: 25,
translate: 27,
});
type DrawFunction = typeof DrawFunction[keyof typeof DrawFunction];
class DrawOperator {
#drawFunc: DrawFunction;
#params: any;
constructor(
drawFunc: DrawFunction,
params: any
) {
this.#drawFunc = drawFunc;
this.#params = params;
}
get drawFunction() {
return this.#drawFunc;
}
get params() {
return this.#params;
}
toJSON() {
return {
drawFunction: this.#drawFunc,
drawParameter: this.#params,
};
}
}
export {
DrawFunction,
DrawOperator,
}

View File

@ -0,0 +1,57 @@
import { DrawOperator } from "./draw_operator";
class PageInfo {
#pageNumber: number;
#layerIndex: number;
#width: number;
#height: number;
#operators: DrawOperator[];
constructor(
pageNumber: number,
layerIndex: number,
width: number,
height: number,
operators?: DrawOperator[]
) {
this.#pageNumber = pageNumber;
this.#layerIndex = layerIndex;
this.#width = width;
this.#height = height;
this.#operators = operators || [];
}
get pageNumber() {
return this.#pageNumber;
}
get layerIndex() {
return this.#layerIndex;
}
get width() {
return this.#width;
}
get height() {
return this.#height;
}
get operators() {
return this.#operators;
}
toJSON() {
return {
pageNumber: this.#pageNumber,
layerIndex: this.#layerIndex,
width: this.#width,
height: this.#height,
drawOperators: this.#operators,
};
}
}
export {
PageInfo,
}

87
src/display/display.ts Normal file
View File

@ -0,0 +1,87 @@
import { InlineDocument } from "../components/document";
import { PageInfo } from "../components/page_info";
import { CanvasFactory } from "./display_util";
import { RendererPage } from "./renderer_page";
const MAX_PIXELS = 256 ** 3;
class Display {
#document: InlineDocument;
scale: number;
constructor(
document: InlineDocument
) {
this.#document = document;
this.scale = 1;
}
get document() {
return this.#document;
}
getTotalLayerCount() {
return this.#document.pages.length;
}
getLayerCount(
pageNum: number
) {
return this.#document.pages.filter(p => p.pageNumber === pageNum).length;
}
async drawPage(
pageNum: number
) {
const pages = this.#document.pages.filter(p => p.pageNumber === pageNum);
if (pages.length === 0) {
throw new RangeError(`Page not found.`);
}
const canvasFactory = new CanvasFactory();
try {
const renderer = new RendererPage(pages[0], canvasFactory);
return await renderer.draw(0, 0, []);
} finally {
canvasFactory.dispose();
}
}
async drawPageInfo(
pageInfo: PageInfo
) {
const canvasFactory = new CanvasFactory();
try {
const renderer = new RendererPage(pageInfo, canvasFactory);
const transform = [this.scale, 0, 0, this.scale, 0, 0];
let width = Math.floor(pageInfo.width * this.scale);
let height = Math.floor(pageInfo.height * this.scale);
const pixelsInViewport = width * height;
const maxScale = Math.sqrt(MAX_PIXELS / pixelsInViewport);
if (1 > maxScale) {
transform[0] = maxScale;
transform[3] = maxScale;
width = Math.floor(width * maxScale);
height = Math.floor(height * maxScale);
}
const bitmap = await renderer.draw(width, height, transform);
return {
bitmap,
width,
height,
transform,
maxScale
};
} finally {
canvasFactory.dispose();
}
}
}
export {
Display,
}

View File

@ -0,0 +1,76 @@
const isSupportOffscreenCanvas = typeof OffscreenCanvas === "function";
type CanvasContext = {
canvas: OffscreenCanvas | HTMLCanvasElement,
context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D
};
type FontStyle = "bold" | "italic" | "italic bold" | "";
class BaseCanvasFactory {
static create(
width: number,
height: number
): CanvasContext {
if (isSupportOffscreenCanvas) {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d');
if (context === null) {
throw new RangeError(`Out of memory`);
}
return { canvas, context };
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (context === null) {
throw new RangeError(`Out of memory`);
}
return { canvas, context };
}
}
class CanvasFactory {
#canvases: Map<string, CanvasContext>;
constructor() {
this.#canvases = new Map();
}
getCanvas(
id: string,
width: number,
height: number
) {
if (this.#canvases.has(id)) {
const ctx = this.#canvases.get(id)!;
ctx.canvas.width = width;
ctx.canvas.height = height;
return ctx;
}
const ctx = BaseCanvasFactory.create(width, height);
this.#canvases.set(id, ctx);
return ctx;
}
dispose() {
for (const ctx of this.#canvases.values()) {
ctx.canvas.width = 0;
ctx.canvas.height = 0;
}
this.#canvases.clear();
}
}
export {
FontStyle,
BaseCanvasFactory,
CanvasFactory,
CanvasContext,
}

View File

@ -0,0 +1,85 @@
import { DrawFunction } from "../components/draw_operator";
import { PageInfo } from "../components/page_info";
import { CanvasContext, CanvasFactory, FontStyle } from "./display_util";
class RendererPage {
#pageInfo: PageInfo;
#canvasFactory: CanvasFactory;
#canvas: CanvasContext | null;
constructor(
pageInfo: PageInfo,
canvasFactory: CanvasFactory,
) {
this.#pageInfo = pageInfo;
this.#canvasFactory = canvasFactory;
this.#canvas = null;
}
async draw(
width: number,
height: number,
transform: number[]
) {
if (this.#canvas === null) {
this.#canvas = this.#canvasFactory.getCanvas(
`p${this.#pageInfo.pageNumber}_l${this.#pageInfo.layerIndex}`,
width,
height,
);
this.#canvas.context.setTransform(new DOMMatrix(transform));
}
for (const drawOperator of this.#pageInfo.operators)
{
const self = this as any;
const drawer = self[drawOperator.drawFunction] as Function | undefined;
if (drawer === undefined) {
const drawMethod = Object.keys(DrawFunction).find(v => (DrawFunction as any)[v] === drawOperator.drawFunction)!;
const renderFunc = (this.#canvas.context as any)[drawMethod] as Function | undefined;
renderFunc?.apply(this.#canvas.context, drawOperator.params);
} else {
await drawer.apply(this, drawOperator.params);
}
}
return await createImageBitmap(this.#canvas.canvas);
}
transform(
transform: number[]
) {
const [ a, b, c, d, e, f ] = transform;
this.#canvas!.context.transform(a, b, c, d, e, f);
}
fillStyle(
fillStyle: string
) {
this.#canvas!.context.fillStyle = fillStyle;
}
strokeStyle(
strokeStyle: string
) {
this.#canvas!.context.strokeStyle = strokeStyle;
}
setFont(
fontFamily: string,
size: number,
fontStyle: FontStyle
) {
this.#canvas!.context.font = `${fontStyle} ${size}px ${fontFamily}`;
}
}
for (const dop of Object.keys(DrawFunction)) {
const idop = (DrawFunction as any)[dop];
(RendererPage.prototype as any)[idop] = (RendererPage.prototype as any)[dop];
}
export {
RendererPage,
}

20
src/inlinedocument.ts Normal file
View File

@ -0,0 +1,20 @@
/***
* Typescriptファイル
*/
import { InlineDocument } from "./components/document";
import { DocumentInfo } from "./components/document_info";
import { DrawFunction, DrawOperator } from "./components/draw_operator";
import { PageInfo } from "./components/page_info";
import { Display } from "./display/display";
import { DocumentReader } from "./io/document_reader";
(window as any).InlineDocument = {
InlineDocument,
DocumentInfo,
DrawFunction,
DrawOperator,
PageInfo,
Display,
DocumentReader,
};

51
src/io/document_reader.ts Normal file
View File

@ -0,0 +1,51 @@
import { InlineDocument } from "../components/document";
import { DocumentInfo } from "../components/document_info";
import { DrawFunction, DrawOperator } from "../components/draw_operator";
import { PageInfo } from "../components/page_info";
class DocumentReader {
static Parse(
json: any
) {
if (!json) throw new Error(`Invalid json data.`);
const docInfo = this.#ParseDocumentInfo(json.documentInfo);
const pages = [] as PageInfo[];
for (const pageJson of json.pages) {
pages.push(this.#ParsePages(pageJson));
}
return new InlineDocument(docInfo, pages);
}
static #ParseDocumentInfo(
json: any
) {
if (!json) throw new Error(`Invalid json data.`);
const title = json.title as string;
const author = json.author as string;
return new DocumentInfo(title, author);
}
static #ParsePages(
json: any
) {
const pageNumber = json.pageNumber as number;
const layerIndex = json.layerIndex as number;
const width = json.width as number;
const height = json.height as number;
const drawOperators = [] as DrawOperator[];
for (const dopj of json.drawOperators) {
const drawFunc = dopj.drawFunction as DrawFunction;
drawOperators.push(new DrawOperator(drawFunc, dopj.drawParameter));
}
return new PageInfo(pageNumber, layerIndex, width, height, drawOperators);
}
}
export {
DocumentReader,
}

View File

@ -0,0 +1,34 @@
class PromiseCapability<T = any> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: any) => void;
#settled: boolean;
constructor() {
this.#settled = false;
this.resolve = null!;
this.reject = null!;
this.promise = new Promise((resolve, reject) => {
this.resolve = (value: T) => {
this.#settled = true;
resolve(value);
};
this.reject = (reason?: any) => {
this.#settled = true;
reject(reason);
};
});
}
get settled() {
return this.#settled;
}
}
export {
PromiseCapability,
}

6
src/viewer.ts Normal file
View File

@ -0,0 +1,6 @@
import { Viewer } from "./web/viewer";
document.addEventListener('DOMContentLoaded', () => {
const viewer = new Viewer();
viewer.run();
});

View File

@ -0,0 +1,71 @@
import { Display } from "../display/display";
import { PromiseCapability } from "../utils/promise_capability";
import { ViewPage } from "./view_page";
import { Viewer } from "./viewer";
type RenderingState = "none" | "drawing" | "drawed";
class RenderingQueue {
container: HTMLDivElement;
#viewer: Viewer;
#pages: Map<number, ViewPage>;
#rendering?: PromiseCapability;
#rAF: number | null;
constructor(
container: HTMLDivElement,
viewer: Viewer
) {
this.container = container;
this.#viewer = viewer;
this.#pages = new Map();
this.#rendering = undefined;
this.#rAF = null;
this.#requestRender();
}
update(
display: Display
) {
for (const page of this.#pages.values()) {
page.dispose();
}
this.#pages.clear();
for (const page of display.document.pages) {
const viewPage = new ViewPage(this.container, page, display);
this.#pages.set(page.pageNumber, viewPage);
}
}
async #requestRender() {
let tasked = false;
for (const page of this.#pages.values())
{
if (page.renderingState === "none") {
page.lastScale = page.display.scale;
await page.draw();
tasked = true;
break;
} else if (page.renderingState === "drawed" && page.lastScale !== page.display.scale) {
page.lastScale = page.display.scale;
page.reset();
await page.draw();
tasked = true;
break;
}
}
if (!tasked) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
requestAnimationFrame(this.#requestRender.bind(this));
}
}
export {
RenderingState,
RenderingQueue,
}

87
src/web/view_page.ts Normal file
View File

@ -0,0 +1,87 @@
import { PageInfo } from "../components/page_info";
import { Display } from "../display/display";
import { PromiseCapability } from "../utils/promise_capability";
import { RenderingState } from "./rendering_queue";
class ViewPage {
pageInfo: PageInfo;
display: Display;
div: HTMLDivElement;
canvas?: HTMLCanvasElement;
renderingState: RenderingState;
lastScale: number;
#drawPromise?: PromiseCapability;
constructor(
container: HTMLDivElement,
pageInfo: PageInfo,
display: Display
) {
this.pageInfo = pageInfo;
this.display = display;
this.div = document.createElement('div');
this.div.classList.add('page');
this.renderingState = "none";
this.lastScale = 0;
this.#drawPromise = undefined;
container.append(this.div);
}
reset() {
if (this.renderingState === "drawing") {
throw new Error(`ViewPage is currently drawing.`);
}
if (this.canvas) {
this.canvas.width = 0;
this.canvas.height = 0;
this.canvas.remove();
}
this.renderingState = "none";
}
async draw() {
if (this.renderingState === "drawed" || this.renderingState === "drawing") {
throw new Error(`Invalid rendering state.`);
}
this.renderingState = "drawing";
this.#drawPromise = new PromiseCapability();
try {
const canvas = this.canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context === null) {
throw new RangeError('Out of memory');
}
const { bitmap } = await this.display.drawPageInfo(this.pageInfo);
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.style.width = `${Math.floor(this.pageInfo.width * this.display.scale)}px`;
canvas.style.height = `${Math.floor(this.pageInfo.height * this.display.scale)}px`;
context.drawImage(bitmap, 0, 0);
this.div.style.width = `${Math.floor(this.pageInfo.width * this.display.scale)}px`;
this.div.style.height = `${Math.floor(this.pageInfo.height * this.display.scale)}px`;
this.div.append(canvas);
} finally {
this.renderingState = "drawed";
this.#drawPromise.resolve(null);
}
}
async dispose() {
if (this.renderingState === "drawing") {
await this.#drawPromise?.promise;
}
this.reset();
this.div.remove();
}
}
export {
ViewPage,
}

130
src/web/viewer.ts Normal file
View File

@ -0,0 +1,130 @@
import { Display } from "../display/display";
import { DocumentReader } from "../io/document_reader";
import { RenderingQueue } from "./rendering_queue";
class Viewer {
#display?: Display;
#renderingQueue;
constructor() {
const container = document.getElementById('viewer') as HTMLDivElement;
this.#display = undefined;
this.#renderingQueue = new RenderingQueue(container, this);
}
get document() {
return this.#display?.document;
}
async run() {
await this.init();
}
async init() {
const openFile = document.getElementById('openFile') as HTMLInputElement;
const openFileButton = document.getElementById('openFileButton') as HTMLButtonElement;
openFile.addEventListener('change', async () => {
if (!openFile.files) {
return;
}
if (!openFile.files[0]) {
return;
}
try {
const text = await openFile.files[0].text();
const json = JSON.parse(text);
const idoc = DocumentReader.Parse(json);
this.#display = new Display(idoc);
this.#renderingQueue.update(this.#display);
openFile.value = ``;
} catch (reason) {
console.error(reason);
}
});
openFileButton.addEventListener('click', () => {
openFile.click();
});
window.addEventListener('wheel', e => {
if (!e.ctrlKey) {
return;
}
e.preventDefault();
const vectorY = e.deltaY;
requestAnimationFrame(() => {
if (vectorY > 0) {
this.scaleDown(Math.abs(vectorY / 10));
} else {
this.scaleUp(Math.abs(vectorY / 10));
}
});
}, { passive: false });
window.addEventListener('keydown', e => {
if (!e.ctrlKey) {
return;
}
let rAF = null;
switch (e.keyCode) {
case 187:
rAF = requestAnimationFrame(() => {
this.scaleUp();
});
break;
case 189:
rAF = requestAnimationFrame(() => {
this.scaleDown();
});
break;
}
if (rAF) {
e.preventDefault();
}
}, { passive: false });
}
scaleDown(
scaleFactor: number = 1
) {
if (this.#display) {
this.scale(this.#display.scale - 0.01 * scaleFactor);
}
}
scaleUp(
scaleFactor: number = 1
) {
if (this.#display) {
this.scale(this.#display.scale + 0.01 * scaleFactor);
}
}
scale(
scale: number
) {
if (!this.#display) {
return;
}
scale = this.#display.scale = Math.min(10, Math.max(scale, 0.1));
const option = document.getElementById('customScale') as HTMLOptionElement;
option.value = option.textContent = `${(scale * 100).toFixed(2)}%`;
}
}
export {
Viewer,
}

10
tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
//
"sourceMap": true,
"target": "ES2015",
"module": "ES2015",
//
"strict": true
}
}

38
webpack.config.js Normal file
View File

@ -0,0 +1,38 @@
const path = require('path');
module.exports = {
// モード値を production に設定すると最適化された状態で、
// development に設定するとソースマップ有効でJSファイルが出力される
mode: 'development',
entry: {
inlinedocument: './src/inlinedocument.ts',
viewer: './src/viewer.ts',
},
devtool: 'source-map',
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'public/dist'),
},
module: {
rules: [
{
// 拡張子 .ts の場合
test: /\.ts$/,
// TypeScript をコンパイルする
use: 'ts-loader',
},
],
},
// import 文で .ts ファイルを解決するため
// これを定義しないと import 文で拡張子を書く必要が生まれる。
// フロントエンドの開発では拡張子を省略することが多いので、
// 記載したほうがトラブルに巻き込まれにくい。
resolve: {
// 拡張子を配列で指定
extensions: [
'.ts', '.js',
],
},
};