Scrapbox書籍のformat@0.2.0
特徴
タイトルに見出し番号と見出しに含まれる最初のページ番号を入れる
目次には更に書名を入れておく
table:index
見出し start end 子の数
1 0 2 3
1.1 3 3
1.2 x x 2
1.2.1 4 6 0
1.2.2 6 12 0
column1 13 14 0
2 2
2.1 15 16
2.2 17 20 1
column2 19 19 0
3 20 24 0
xもしくは空欄はページなし
子の数で、含まれる節の数を示す
省略した場合は0とみなす
この例の場合、以下のような階層構造になる
1
1.1
1.2
1.2.1
1.2.2
column1
2
2.1
2.2
column2
3
parserとstrinigfyの実装
code:mod.ts
import { toAsyncIterable } from "jsr:@core/iterutil@0.9/async/to-async-iterable";
export interface Page {
/** ページタイトル */
title: string;
/** このページに含める画像のインデックス */
indice: number[];
/** 前ページのタイトル
*
* なければ空文字
*/
prev: string;
/** 次ページのタイトル
*
* なければ空文字
*/
next: string;
/** 子ページのタイトル */
children: string[];
}
export interface PageSource {
url: string | URL;
text: string;
}
/** ページ情報と画像URLとテキストから、scrapboxのページを作る TransformStream */
export class PageStringifyStream extends TransformStream<Page, string[]> {
#iter: AsyncIterator<PageSource>; const item = this.#stack.at(index);
if (item) return item;
await this.#iterReady;
const ready = (async () => {
while (true) {
const { done, value } = await this.#iter.next();
if (done) return;
this.#stack.push(value);
if (index === this.#stack.length - 1) return value;
}
})();
this.#iterReady = ready as Promise<void>;
return ready;
}
/**
* @param source 書籍に使う画像のURLとテキストのセット
* @param offset=0 書籍formatで指定したページ番号が0以外で始まる場合、この数値を変えて調節する */
constructor(
source: Iterable<PageSource> | AsyncIterable<PageSource>,
offset=0,
) {
super({
transform: async (page, controller) => {
controller.enqueue([
page.title,
...(await Promise.all(page.indice.map(async (i) => {
const source = await this.#at(i+offset);
if (!source) {
console.warn(Could not find the image source for ${i}.);
return [];
}
return [
...source.text.split("\n"),
"",
[${source.url}],
"",
];
}))).flat(),
...page.children.map((title) => [${title}]),
"",
page.next !== "" ? [${page.next}] : ""
} =>`,
"",
]);
},
});
this.#iter = (Symbol.iterator in source ? toAsyncIterable(source) : source)
}
}
/** 生成したページデータを並び替える
* @example
* `ts
* import { CsvParseStream } from "jsr:@std/csv@1/parse-stream";
* import { assertEquals } from "jsr:@std/assert@1/equals";
*
* const csv = `1,0,2,3
* 1.1,3,3,
* 1.2,x,x,2
* 1.2.1,4,6,0
* 1.2.2,6,12,0
* column1,13,14,0
* 2,,,2
* 2.1,15,16,
* 2.2,17,20,1
* column2,19,19,0
* 3,20,24,0`;
* const sorted = ReadableStream.from(csv) * .pipeThrough(new CsvParseStream())
* .pipeThrough(new IndexParseStream())
* .pipeThrough(new PageSortStream());
*
* assertEquals<Page[]>(await Array.fromAsync(sorted), [
* {
* title: "1",
* next: "2",
* prev: "",
* },
* { title: "1.1", next: "1.2", prev: "1", indice: 3, children: [] }, * {
* title: "1.2",
* next: "column1",
* prev: "1.1",
* indice: [],
* },
* {
* title: "1.2.1",
* next: "1.2.2",
* prev: "1.2",
* children: [],
* },
* {
* title: "1.2.2",
* next: "column1",
* prev: "1.2.1",
* children: [],
* },
* {
* title: "column1",
* next: "2",
* prev: "1.2",
* children: [],
* },
* {
* title: "2",
* next: "3",
* prev: "1",
* indice: [],
* },
* { title: "2.1", next: "2.2", prev: "2", indice: 15, 16, children: [] }, * {
* title: "2.2",
* next: "3",
* prev: "2.1",
* },
* { title: "column2", next: "3", prev: "2.2", indice: 19, children: [] }, * {
* title: "3",
* next: "",
* prev: "2",
* children: [],
* },
* ]);
* `
*/
export class PageSortStream extends TransformStream<Page, Page> {
const index = this.#stack.findIndex((page) => page.title === title);
if (index < 0) return;
const target = this.#stack.splice(index, 1); return target;
}
constructor() {
let returnedFirst = false;
let next = "";
const enqueueAndSetNext = (
page: Page,
controller: TransformStreamDefaultController<Page>,
) => {
controller.enqueue(page);
next = page.children.at(0) ?? page.next;
};
super({
transform: (page, controller) => {
if (page.title === next || (!returnedFirst && page.prev === "")) {
if (!returnedFirst && page.prev === "") returnedFirst = true;
enqueueAndSetNext(page, controller);
return;
}
this.#stack.push(page);
},
flush: (controller) => {
if (!returnedFirst) {
const first = this.#stack.find((page) => page.prev === "");
if (!first) throw new Error("Could not find the first page.");
enqueueAndSetNext(first, controller);
}
while (next !== "") {
const page = this.#find(next);
if (!page) throw new Error(Could not find the next page "${next}".);
enqueueAndSetNext(page, controller);
}
},
});
}
}
/**
* @example
* `ts
* import { CsvParseStream } from "jsr:@std/csv@1/parse-stream";
* import { assertEquals } from "jsr:@std/assert@1/equals";
*
* const csv = `1,0,2,3
* 1.1,3,3,
* 1.2,x,x,2
* 1.2.1,4,6,0
* 1.2.2,6,12,0
* column1,13,14,0
* 2,,,2
* 2.1,15,16,
* 2.2,17,20,1
* column2,19,19,0
* 3,20,24,0`;
* const data = ReadableStream.from(csv) * .pipeThrough(new CsvParseStream())
* .pipeThrough(new IndexParseStream());
*
* assertEquals<Page[]>(await Array.fromAsync(data), [
* { title: "1.1", next: "1.2", prev: "1", indice: 3, children: [] }, * {
* title: "1.2.1",
* next: "1.2.2",
* prev: "1.2",
* children: [],
* },
* {
* title: "1.2.2",
* next: "column1",
* prev: "1.2.1",
* children: [],
* },
* {
* title: "1.2",
* next: "column1",
* prev: "1.1",
* indice: [],
* },
* {
* title: "column1",
* next: "2",
* prev: "1.2",
* children: [],
* },
* {
* title: "1",
* next: "2",
* prev: "",
* },
* { title: "2.1", next: "2.2", prev: "2", indice: 15, 16, children: [] }, * { title: "column2", next: "3", prev: "2.2", indice: 19, children: [] }, * {
* title: "2.2",
* next: "3",
* prev: "2.1",
* },
* {
* title: "2",
* next: "3",
* prev: "1",
* indice: [],
* },
* {
* title: "3",
* next: "",
* prev: "2",
* children: [],
* },
* ]);
* `
*/
export class IndexParseStream extends TransformStream<string[], Page> {
constructor() {
const parents: { page: Page; childNum: number }[] = [];
super({
transform(row, controller) {
// for loopの都合上、parentsには先頭から要素を追加する
// 階層の深い順にparentが並ぶ
const s = /^\d+$/.test(start) ? Math.abs(parseInt(start)) : -1;
const e = /^\d+$/.test(end) ? Math.abs(parseInt(end)) : -1;
const childNum = /^\d+$/.test(childNumText)
? Math.abs(parseInt(childNumText))
: 0;
const page: Page = { title, indice, prev: "", next: "", children: [] };
// 親見出しから処理する
let i = 0;
for (; i < parents.length; i++) {
// pageを親見出しの子とする場合は、追加処理を行ってループを抜ける
if (
parent.childNum !== 0 &&
parent.childNum > parent.page.children.length
) {
// 親見出しの子にする
parent.page.children.push(page.title);
// 最初の子の場合のみ、親見出しとつなげる
if (parent.page.children.length === 1) {
page.prev = parent.page.title;
}
break;
}
// 見出しが完成しているときは、pageを同階層の次の見出しとみなす
// 小見出しがない見出しもここで処理される
parent.page.next = page.title;
page.prev = parent.page.title;
}
// 完成済みの見出しをparentsから削って返す
// 添字がi未満のparentsが、完成した見出しとなる
for (const { page } of parents.splice(0, i)) {
controller.enqueue(page);
}
// 一旦parentsにためてから処理する
parents.unshift({ page, childNum });
},
flush(controller) {
for (const { page } of parents) {
controller.enqueue(page);
}
},
});
}
}
function* range(start: number, end: number) {
for (let i = start; i <= end; i++) {
yield i;
}
}
ReadableStreamベースにし、OCRの配列を全てfetchしなくてもstringifyできるようにするなどした