File size: 5,228 Bytes
a1f71dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
// parser.js - Parse .xopp (gzipped XML) and .xoj (plain XML) files

export async function parseFile(file) {
    const buffer = await file.arrayBuffer();

    const sha256 = await computeSha256(buffer);

    let xmlString;
    try {
        xmlString = await decompressGzip(buffer);
    } catch {
        xmlString = new TextDecoder().decode(buffer);
    }

    const parser = new DOMParser();
    const doc = parser.parseFromString(xmlString, 'text/xml');

    const parserError = doc.querySelector('parsererror');
    if (parserError) {
        throw new Error('Failed to parse XML: ' + parserError.textContent);
    }

    const result = extractDocument(doc);
    result.sha256 = sha256;
    return result;
}

async function computeSha256(buffer) {
    const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

async function decompressGzip(buffer) {
    const ds = new DecompressionStream('gzip');
    const writer = ds.writable.getWriter();
    writer.write(new Uint8Array(buffer));
    writer.close();

    const reader = ds.readable.getReader();
    const chunks = [];
    while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        chunks.push(value);
    }

    const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
    const result = new Uint8Array(totalLength);
    let offset = 0;
    for (const chunk of chunks) {
        result.set(chunk, offset);
        offset += chunk.length;
    }

    return new TextDecoder().decode(result);
}

function extractDocument(doc) {
    const root = doc.documentElement;
    const pages = [];

    const pageElements = root.querySelectorAll('page');
    for (let pageIdx = 0; pageIdx < pageElements.length; pageIdx++) {
        const pageEl = pageElements[pageIdx];
        const width = parseFloat(pageEl.getAttribute('width') || '612');
        const height = parseFloat(pageEl.getAttribute('height') || '792');

        const strokes = [];
        let globalStrokeIdx = 0;

        // Each layer is tracked separately so stroke_indices in the schema
        // refer to positions within the layer, matching the source XML.
        const layers = pageEl.querySelectorAll('layer');
        for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
            const layer = layers[layerIdx];
            const strokeElements = layer.querySelectorAll('stroke');
            for (let inLayerIdx = 0; inLayerIdx < strokeElements.length; inLayerIdx++) {
                const stroke = parseStroke(
                    strokeElements[inLayerIdx],
                    globalStrokeIdx,
                    layerIdx,
                    inLayerIdx
                );
                if (stroke) {
                    strokes.push(stroke);
                    globalStrokeIdx++;
                }
                // inLayerIdx always advances so it matches the XML element position,
                // even when a stroke element is skipped (no points).
            }
        }

        pages.push({ index: pageIdx, width, height, strokes });
    }

    return { pages };
}

function parseStroke(strokeEl, globalIndex, layerIndex, indexInLayer) {
    const tool = strokeEl.getAttribute('tool') || 'pen';
    const colorAttr = strokeEl.getAttribute('color') || 'black';
    const widthAttr = strokeEl.getAttribute('width') || '1.0';

    const widths = widthAttr.trim().split(/\s+/).map(Number);
    const baseWidth = widths[0];

    const text = strokeEl.textContent.trim();
    if (!text) return null;

    const values = text.split(/\s+/).map(Number);
    if (values.length < 4) return null;

    const points = [];
    for (let i = 0; i < values.length - 1; i += 2) {
        points.push({ x: values[i], y: values[i + 1] });
    }

    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (const p of points) {
        if (p.x < minX) minX = p.x;
        if (p.y < minY) minY = p.y;
        if (p.x > maxX) maxX = p.x;
        if (p.y > maxY) maxY = p.y;
    }

    return {
        index: globalIndex,    // flat index across all layers, used for selection
        layerIndex,            // which <layer> element within the page (0-based)
        indexInLayer,          // position within the layer's <stroke> elements (0-based)
        tool,
        color: resolveColor(colorAttr),
        width: baseWidth,
        widths: widths.length > 1 ? widths : null,
        points,
        bbox: { minX, minY, maxX, maxY },
    };
}

function resolveColor(colorAttr) {
    const named = {
        black: '#000000',
        blue: '#3333cc',
        red: '#ff0000',
        green: '#008000',
        gray: '#808080',
        lightblue: '#00c0ff',
        lightgreen: '#00ff00',
        magenta: '#ff00ff',
        orange: '#ff8000',
        yellow: '#ffff00',
        white: '#ffffff',
    };

    if (named[colorAttr]) return named[colorAttr];

    if (colorAttr.startsWith('#')) {
        return colorAttr.substring(0, 7);
    }

    if (colorAttr.startsWith('rgba') || colorAttr.startsWith('rgb')) {
        return colorAttr;
    }

    return '#000000';
}