| |
|
|
| const STORAGE_PREFIX = 'xopp-annotator:'; |
| const ANNOTATOR_ID_KEY = 'xopp-annotator:annotator-id'; |
|
|
| export const SCHEMA_VERSION = '1.0.0'; |
|
|
| |
| export const TEXT_REQUIRED_CLASSES = new Set(['word', 'digit', 'mathematical_expression']); |
|
|
| const VALID_CLASSES = new Set([ |
| 'word', 'digit', 'mathematical_expression', 'arrow', 'diagram', |
| 'table', 'drawing', 'separator', 'correction', 'other', |
| ]); |
|
|
| |
|
|
| export function getAnnotatorId() { |
| return localStorage.getItem(ANNOTATOR_ID_KEY) || ''; |
| } |
|
|
| export function saveAnnotatorId(id) { |
| localStorage.setItem(ANNOTATOR_ID_KEY, id); |
| } |
|
|
| |
|
|
| export function getStorageKey(fileName) { |
| return STORAGE_PREFIX + fileName; |
| } |
|
|
| export function autoSave(fileName, state) { |
| const key = getStorageKey(fileName); |
| const data = buildExportData(fileName, state); |
| try { |
| localStorage.setItem(key, JSON.stringify(data)); |
| } catch (e) { |
| console.warn('Auto-save failed:', e); |
| } |
| } |
|
|
| export function autoLoad(fileName) { |
| const key = getStorageKey(fileName); |
| try { |
| const raw = localStorage.getItem(key); |
| if (!raw) return null; |
| return JSON.parse(raw); |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function validateExportData(data) { |
| const errors = []; |
|
|
| if (!data || typeof data !== 'object') { |
| return { valid: false, errors: ['Root value must be an object'] }; |
| } |
|
|
| |
| for (const field of ['schema_version', 'source_document', 'annotator_id', 'created_at', 'annotations']) { |
| if (!(field in data)) errors.push(`Missing required field: "${field}"`); |
| } |
| if (errors.length) return { valid: false, errors }; |
|
|
| if (data.schema_version !== SCHEMA_VERSION) { |
| errors.push(`schema_version must be "${SCHEMA_VERSION}", got "${data.schema_version}"`); |
| } |
|
|
| if (typeof data.annotator_id !== 'string' || data.annotator_id.length < 1) { |
| errors.push('annotator_id must be a non-empty string'); |
| } |
|
|
| if (typeof data.created_at !== 'string' || isNaN(Date.parse(data.created_at))) { |
| errors.push('created_at must be a valid ISO 8601 date-time string'); |
| } |
|
|
| if (!data.source_document || typeof data.source_document !== 'object') { |
| errors.push('source_document must be an object'); |
| } else { |
| if (!data.source_document.filename) { |
| errors.push('source_document.filename is required'); |
| } |
| if (!/^[a-f0-9]{64}$/.test(data.source_document.sha256 || '')) { |
| errors.push('source_document.sha256 must be a 64-character lowercase hex string'); |
| } |
| } |
|
|
| if (!Array.isArray(data.annotations)) { |
| errors.push('annotations must be an array'); |
| } else { |
| for (let i = 0; i < data.annotations.length; i++) { |
| const ann = data.annotations[i]; |
| const prefix = `annotations[${i}]`; |
|
|
| for (const field of ['class', 'page_index', 'layer_index', 'stroke_indices']) { |
| if (!(field in ann)) errors.push(`${prefix}: missing required field "${field}"`); |
| } |
|
|
| if (ann.class !== undefined && !VALID_CLASSES.has(ann.class)) { |
| errors.push(`${prefix}: invalid class "${ann.class}"`); |
| } |
|
|
| if (typeof ann.page_index !== 'number' || !Number.isInteger(ann.page_index) || ann.page_index < 0) { |
| errors.push(`${prefix}: page_index must be a non-negative integer`); |
| } |
|
|
| if (typeof ann.layer_index !== 'number' || !Number.isInteger(ann.layer_index) || ann.layer_index < 0) { |
| errors.push(`${prefix}: layer_index must be a non-negative integer`); |
| } |
|
|
| if (!Array.isArray(ann.stroke_indices) || ann.stroke_indices.length === 0) { |
| errors.push(`${prefix}: stroke_indices must be a non-empty array`); |
| } else { |
| const unique = new Set(ann.stroke_indices); |
| if (unique.size !== ann.stroke_indices.length) { |
| errors.push(`${prefix}: stroke_indices must contain unique values`); |
| } |
| } |
|
|
| |
| |
| if (ann.class && TEXT_REQUIRED_CLASSES.has(ann.class)) { |
| if (typeof ann.text !== 'string' || ann.text.length === 0) { |
| errors.push(`${prefix}: class "${ann.class}" requires a non-empty "text" field`); |
| } |
| } else if (ann.class && !TEXT_REQUIRED_CLASSES.has(ann.class)) { |
| if ('text' in ann) { |
| errors.push(`${prefix}: class "${ann.class}" must not have a "text" field`); |
| } |
| } |
| } |
| } |
|
|
| return { valid: errors.length === 0, errors }; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| export function buildExportData(fileName, state) { |
| const annotations = []; |
|
|
| for (let pageIdx = 0; pageIdx < state.annotations.length; pageIdx++) { |
| const pageAnns = state.annotations[pageIdx] || []; |
| const page = state.pages[pageIdx]; |
| if (!page) continue; |
|
|
| const strokeByGlobal = new Map(); |
| for (const stroke of page.strokes) { |
| strokeByGlobal.set(stroke.index, stroke); |
| } |
|
|
| for (const ann of pageAnns) { |
| |
| const byLayer = new Map(); |
| for (const globalIdx of ann.strokeIndices) { |
| const stroke = strokeByGlobal.get(globalIdx); |
| if (!stroke) continue; |
| const li = stroke.layerIndex; |
| if (!byLayer.has(li)) byLayer.set(li, []); |
| byLayer.get(li).push(stroke.indexInLayer); |
| } |
|
|
| for (const [layerIdx, strokeIndices] of byLayer) { |
| const entry = { |
| class: ann.className, |
| page_index: pageIdx, |
| layer_index: layerIdx, |
| stroke_indices: strokeIndices.sort((a, b) => a - b), |
| }; |
| if (TEXT_REQUIRED_CLASSES.has(ann.className) && ann.text) { |
| entry.text = ann.text; |
| } |
| annotations.push(entry); |
| } |
| } |
| } |
|
|
| return { |
| schema_version: SCHEMA_VERSION, |
| annotator_id: state.annotatorId || '', |
| created_at: state.createdAt || new Date().toISOString(), |
| source_document: { |
| filename: fileName, |
| sha256: state.sha256 || '', |
| }, |
| annotations, |
| }; |
| } |
|
|
| export function exportJSON(fileName, state) { |
| const data = buildExportData(fileName, state); |
|
|
| const validation = validateExportData(data); |
| if (!validation.valid) { |
| throw new Error('Schema validation failed:\n\n' + validation.errors.join('\n')); |
| } |
|
|
| const json = JSON.stringify(data, null, 2); |
| const blob = new Blob([json], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
|
|
| const a = document.createElement('a'); |
| a.href = url; |
| const baseName = fileName.replace(/\.(xopp|xoj)$/i, ''); |
| a.download = `${baseName}.gt.json`; |
| a.click(); |
|
|
| URL.revokeObjectURL(url); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function restoreState(savedData, pages, expectedSha256 = null) { |
| if (!savedData || savedData.schema_version !== SCHEMA_VERSION) return null; |
|
|
| if (expectedSha256 && savedData.source_document?.sha256 !== expectedSha256) { |
| console.warn('SHA-256 mismatch in auto-saved data — discarding stale annotations'); |
| return null; |
| } |
|
|
| |
| const strokeLookup = new Map(); |
| for (const page of pages) { |
| for (const stroke of page.strokes) { |
| const key = `${page.index}:${stroke.layerIndex}:${stroke.indexInLayer}`; |
| strokeLookup.set(key, stroke.index); |
| } |
| } |
|
|
| const annotations = pages.map(() => []); |
|
|
| for (let i = 0; i < savedData.annotations.length; i++) { |
| const ann = savedData.annotations[i]; |
| if (ann.page_index >= pages.length) continue; |
|
|
| const strokeIndices = new Set(); |
| for (const si of ann.stroke_indices) { |
| const key = `${ann.page_index}:${ann.layer_index}:${si}`; |
| const globalIdx = strokeLookup.get(key); |
| if (globalIdx !== undefined) strokeIndices.add(globalIdx); |
| } |
|
|
| if (strokeIndices.size > 0) { |
| const textValue = ann.text || ''; |
| if (TEXT_REQUIRED_CLASSES.has(ann.class) && !textValue) { |
| console.warn(`annotations[${i}] (class "${ann.class}") is missing required text — loaded with empty text`); |
| } |
| annotations[ann.page_index].push({ |
| id: generateId(), |
| className: ann.class, |
| strokeIndices, |
| text: textValue, |
| }); |
| } |
| } |
|
|
| return { |
| annotations, |
| createdAt: savedData.created_at, |
| annotatorId: savedData.annotator_id || '', |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function loadGroundTruth(file, expectedSha256, pages) { |
| const text = await file.text(); |
| let data; |
| try { |
| data = JSON.parse(text); |
| } catch { |
| throw new Error('Invalid JSON file.'); |
| } |
|
|
| |
| const validation = validateExportData(data); |
| if (!validation.valid) { |
| throw new Error('Schema validation failed:\n\n' + validation.errors.join('\n')); |
| } |
|
|
| |
| const fileSha256 = data.source_document?.sha256; |
| if (fileSha256 !== expectedSha256) { |
| throw new Error( |
| `SHA-256 mismatch — the .gt.json was created from a different version of the source document.\n\n` + |
| `Expected: ${expectedSha256}\n` + |
| `In file: ${fileSha256}` |
| ); |
| } |
|
|
| const restored = restoreState(data, pages); |
| if (!restored) throw new Error('Failed to restore annotations from .gt.json.'); |
| return restored; |
| } |
|
|
| function generateId() { |
| return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); |
| } |
|
|