/* This file is part of the Notesnook project (https://notesnook.com/) Copyright (C) 2023 Streetwriters (Private) Limited This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ import { PropertyType } from "./property-type"; import { ObjectProps } from "./props"; import { GUID } from "../utils/guid"; export type PropertySetData = T; export function assertPropertySet(props: ObjectProps, expectedId: number) { if (props.jcid.id === expectedId) return; throw new Error( `unexpected object type: 0x${props.jcid.id.toString(16).toUpperCase()}` ); } /** FILETIME: 100-nanosecond intervals since 1601-01-01 → epoch milliseconds. */ export function filetimeToMs(timestamp: number): number { return Math.round(timestamp / 10 / 1000 - 11644473600000); } /** OneNote "Time" values: seconds since 1980-01-01 → epoch milliseconds. */ export function oneNoteTimeToMs(time: number): number { return (time + 315532800) * 1000; } export function parseF32Value(value: number): number { const buffer = new ArrayBuffer(4); const view = new DataView(buffer); view.setUint32(0, value, true); return view.getFloat32(0, true); } // --------------------------------------------------------------------------- // Properties // --------------------------------------------------------------------------- export type Color = { alpha: number; r: number; g: number; b: number }; export function parseColor( props: ObjectProps, propType: PropertyType ): Color | undefined { const value = props.getU32(propType); if (value === undefined) return undefined; const bytes = toLeBytes(value, 4); return { alpha: 255 - bytes[3], r: bytes[0], g: bytes[1], b: bytes[2] }; } export type ColorRef = { type: "auto" } | { type: "manual"; r: number; g: number; b: number }; export function parseColorRef( props: ObjectProps, propType: PropertyType ): ColorRef | undefined { const value = props.getU32(propType); if (value === undefined) return undefined; const bytes = toLeBytes(value, 4); if (bytes[3] === 0xff) return { type: "auto" }; if (bytes[3] === 0x00) return { type: "manual", r: bytes[0], g: bytes[1], b: bytes[2] }; return undefined; } export enum Charset { Ansi, Default, Symbol, Mac, ShiftJis, Hangul, Johab, Gb2312, ChineseBig5, Greek, Turkish, Vietnamese, Hebrew, Arabic, Baltic, Russian, Thai, EastEurope, Oem } export function parseCharset( props: ObjectProps, propType: PropertyType ): Charset | undefined { const value = props.getU8(propType); if (value === undefined) return undefined; switch (value) { case 0: return Charset.Ansi; case 1: return Charset.Default; case 2: return Charset.Symbol; case 77: return Charset.Mac; case 128: return Charset.ShiftJis; case 129: return Charset.Hangul; case 130: return Charset.Johab; case 134: return Charset.Gb2312; case 136: return Charset.ChineseBig5; case 161: return Charset.Greek; case 162: return Charset.Turkish; case 163: return Charset.Vietnamese; case 177: return Charset.Hebrew; case 178: return Charset.Arabic; case 186: return Charset.Baltic; case 204: return Charset.Russian; case 222: return Charset.Thai; case 238: return Charset.EastEurope; case 255: return Charset.Oem; } return undefined; } export enum ParagraphAlignment { Unknown = 0, Left = 1, Center = 2, Right = 3 } export function parseParagraphAlignment( props: ObjectProps ): ParagraphAlignment | undefined { const value = props.getU8Lossless(PropertyType.ParagraphAlignment); if (value === undefined) return undefined; switch (value) { case 0: return ParagraphAlignment.Left; case 1: return ParagraphAlignment.Center; case 2: return ParagraphAlignment.Right; default: return ParagraphAlignment.Unknown; } } export type LayoutAlignment = { alignmentHorizontal: number; alignmentMarginHorizontal: number; alignmentVertical: number; alignmentMarginVertical: number; }; export function parseLayoutAlignment( props: ObjectProps, propType: PropertyType ): LayoutAlignment | undefined { const value = props.getU32(propType); if (value === undefined) return undefined; if ((value >> 31) & 0x1) return undefined; return { alignmentHorizontal: value & 0x7, alignmentMarginHorizontal: (value >> 3) & 0x1, alignmentVertical: (value >> 16) & 0x1, alignmentMarginVertical: (value >> 19) & 0x1 }; } export type NoteTagShape = number; const CHECKABLE_SHAPES = new Set([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 28, 29, 30, 31, 32, 33, 48, 49, 50, 51, 52, 53, 69, 70, 71, 72, 73, 74, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99 ]); export function parseNoteTagShape(value: number): NoteTagShape { return value; } export function isCheckableNoteTagShape(shape: NoteTagShape): boolean { return CHECKABLE_SHAPES.has(shape); } export type NoteTagPropertyStatus = { hasLabel: boolean; hasFontColor: boolean; hasHighlightColor: boolean; hasIcon: boolean; dueToday: boolean; dueTomorrow: boolean; dueThisWeek: boolean; dueNextWeek: boolean; dueLater: boolean; dueCustom: boolean; }; export function parseNoteTagPropertyStatus( props: ObjectProps ): NoteTagPropertyStatus | undefined { const value = props.getU32(PropertyType.NoteTagPropertyStatus); if (value === undefined) return undefined; return { hasLabel: (value & 0x1) !== 0, hasFontColor: ((value >> 1) & 0x1) !== 0, hasHighlightColor: ((value >> 2) & 0x1) !== 0, hasIcon: ((value >> 3) & 0x1) !== 0, dueToday: ((value >> 6) & 0x1) !== 0, dueTomorrow: ((value >> 7) & 0x1) !== 0, dueThisWeek: ((value >> 8) & 0x1) !== 0, dueNextWeek: ((value >> 9) & 0x1) !== 0, dueLater: ((value >> 10) & 0x1) !== 0, dueCustom: ((value >> 11) & 0x1) !== 0 }; } export type ActionItemStatus = { completed: boolean; disabled: boolean; taskTag: boolean; }; export function parseActionItemStatus( props: ObjectProps ): ActionItemStatus | undefined { const value = props.getU16(PropertyType.ActionItemStatus); if (value === undefined) return undefined; return { completed: (value & 0x1) !== 0, disabled: ((value >> 1) & 0x1) !== 0, taskTag: ((value >> 2) & 0x1) !== 0 }; } export type ActionItemType = | { type: "numeric"; value: number } | { type: "due"; value: "today" | "tomorrow" | "thisWeek" | "nextWeek" | "noDueDate" | "custom" } | { type: "unknown" }; export function parseActionItemType( props: ObjectProps ): ActionItemType | undefined { const value = props.getU16(PropertyType.ActionItemType); if (value === undefined) return undefined; if (value >= 0 && value <= 99) return { type: "numeric", value }; switch (value) { case 100: return { type: "due", value: "today" }; case 101: return { type: "due", value: "tomorrow" }; case 102: return { type: "due", value: "thisWeek" }; case 103: return { type: "due", value: "nextWeek" }; case 104: return { type: "due", value: "noDueDate" }; case 105: return { type: "due", value: "custom" }; } return { type: "unknown" }; } export enum FileType { Unknown, Audio, Video } export function parseFileType(props: ObjectProps): FileType { const value = props.getU32(PropertyType.IRecordMedia); if (value === undefined) return FileType.Unknown; switch (value) { case 1: return FileType.Audio; case 2: return FileType.Video; default: return FileType.Unknown; } } export type OutlineIndentDistance = number[]; export function parseOutlineIndentDistance( props: ObjectProps ): OutlineIndentDistance | undefined { const value = props.getVec(PropertyType.RgOutlineIndentDistance); if (!value) return undefined; const count = value[0]; const distances: number[] = []; for (let i = 0; i < count; ++i) { const view = new DataView(value.buffer, value.byteOffset + 4 + i * 4, 4); distances.push(view.getFloat32(0, true)); } return distances; } function readF32Slice(data: Uint8Array): number[] { const result: number[] = []; for (let i = 0; i + 4 <= data.length; i += 4) { const view = new DataView(data.buffer, data.byteOffset + i, 4); result.push(view.getFloat32(0, true)); } return result; } export function toLeBytes(value: number, size: number): number[] { const bytes: number[] = []; for (let i = 0; i < size; ++i) { bytes.push((value >>> (i * 8)) & 0xff); } return bytes; } // --------------------------------------------------------------------------- // Property sets // --------------------------------------------------------------------------- export function parseSectionMetadata(props: ObjectProps) { assertPropertySet(props, 0x00020031); return { displayName: props.getString(PropertyType.SectionDisplayName), color: parseColor(props, PropertyType.SectionColor) }; } export function parseSectionNode(props: ObjectProps) { assertPropertySet(props, 0x00060007); return { pageSeries: props.objectRefs(PropertyType.ElementChildNodes) }; } export function parsePageSeriesNode(props: ObjectProps) { assertPropertySet(props, 0x00060008); const SEED = "22a8c031-3600-42ee-b714-d7acda2435e8"; return { pageSpaces: props.objectSpaceRefs(PropertyType.ChildGraphSpaceElementNodes), pageMetadata: props .objectRefs(PropertyType.MetaDataObjectsAboveGraphSpace) .map((item) => xorExtendedGuidKey(item, SEED)) }; } /** * XOR an extended GUID key ({GUID} [n]) with a seed GUID. * Mirrors the Rust implementation which XORs the ExGuid (guid + n) with * the seed {22a8c031-3600-42ee-b714-d7acda2435e8, 0}. */ export function xorExtendedGuidKey(key: string, seedGuid: string): string { const match = /^(.*) \[(\d+)\]$/.exec(key); if (!match) return key; const guid = match[1]; const n = Number(match[2]); const guidBytes = guidHexToBytes(guid); const seedBytes = guidHexToBytes(seedGuid); const result = new Array(16); for (let i = 0; i < 16; ++i) result[i] = guidBytes[i] ^ seedBytes[i]; const hex = result .map((b) => b.toString(16).padStart(2, "0")) .join("") .toUpperCase(); const guidString = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice( 12, 16 )}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; return `${guidString} [${n}]`; } function guidHexToBytes(guid: string): number[] { const hex = guid.replace(/-/g, ""); const bytes: number[] = []; for (let i = 0; i < 32; i += 2) { bytes.push(parseInt(hex.slice(i, i + 2), 16)); } return bytes; } export function parsePageManifest(props: ObjectProps) { assertPropertySet(props, 0x00060037); const pages = props.objectRefs(PropertyType.ContentChildNodes); if (!pages.length) throw new Error("page manifest has no page"); return { page: pages[0] }; } export function parsePageMetadata(props: ObjectProps) { assertPropertySet(props, 0x00020030); const entityGuid = props.getGuidString( PropertyType.NotebookManagementEntityGuid ); if (!entityGuid) throw new Error("page metadata has no guid"); const timestamp = props.getU64(PropertyType.TopologyCreationTimeStamp); if (timestamp === undefined) throw new Error("page metadata has no creation timestamp"); return { entityGuid, cachedTitle: props.getString(PropertyType.CachedTitleString), pageLevel: props.getU32(PropertyType.PageLevel) ?? 0, createdAtMs: filetimeToMs(timestamp), isDeleted: props.getBoolOrDefault( PropertyType.IsDeletedGraphSpaceContent, false ) }; } export function parsePageNode(props: ObjectProps) { assertPropertySet(props, 0x0006000b); const lastModified = props.getU32(PropertyType.LastModifiedTime); return { lastModifiedMs: lastModified === undefined ? undefined : oneNoteTimeToMs(lastModified), cachedTitle: props.getString(PropertyType.CachedTitleStringFromPage), author: props.getString(PropertyType.Author), content: props.objectRefs(PropertyType.ElementChildNodes), title: props.objectRefs(PropertyType.StructureElementChildNodes)[0], recognizedText: props.objectRef( PropertyType.PageRecognizedTextContainer ), pageWidth: props.getF32(PropertyType.PageWidth), pageHeight: props.getF32(PropertyType.PageHeight) }; } export function parseTitleNode(props: ObjectProps) { assertPropertySet(props, 0x0006002c); const children = props.objectRefs(PropertyType.ElementChildNodes); if (!children.length) throw new Error("title node has no child nodes"); return { children, offsetHorizontal: props.getF32(PropertyType.OffsetFromParentHoriz) ?? 0, offsetVertical: props.getF32(PropertyType.OffsetFromParentVert) ?? 0, layoutAlignmentInParent: parseLayoutAlignment( props, PropertyType.LayoutAlignmentInParent ), layoutAlignmentSelf: parseLayoutAlignment( props, PropertyType.LayoutAlignmentSelf ) }; } export function parseTocContainer(props: ObjectProps) { assertPropertySet(props, 0x00020001); return { children: props.objectRefs(PropertyType.TocChildren), filename: props .getString(PropertyType.FolderChildFilename) ?.replace("^M", "+") ?.replace("^J", ","), orderingId: props.getU32(PropertyType.NotebookElementOrderingId), color: parseColor(props, PropertyType.SectionColor) }; } export function parseOutlineNode(props: ObjectProps) { assertPropertySet(props, 0x0006000c); const childLevel = props.getU8(PropertyType.OutlineElementChildLevel); if (childLevel === undefined) throw new Error("outline node has no child level"); const indents = parseOutlineIndentDistance(props); if (!indents) throw new Error("outline node has no outline indent distance"); return { children: props.objectRefs(PropertyType.ElementChildNodes), childLevel, indents, listSpacing: props.getF32(PropertyType.ListSpacingMu), alignmentInParent: parseLayoutAlignment( props, PropertyType.LayoutAlignmentInParent ), alignmentSelf: parseLayoutAlignment( props, PropertyType.LayoutAlignmentSelf ), layoutMaxHeight: props.getF32(PropertyType.LayoutMaxHeight), layoutMaxWidth: props.getF32(PropertyType.LayoutMaxWidth), layoutReservedWidth: props.getF32( PropertyType.LayoutOutlineReservedWidth ), layoutMinimumOutlineWidth: props.getF32( PropertyType.LayoutMinimumOutlineWidth ), isLayoutSizeSetByUser: props.getBoolOrDefault( PropertyType.IsLayoutSizeSetByUser, false ), offsetHorizontal: props.getF32(PropertyType.OffsetFromParentHoriz), offsetVertical: props.getF32(PropertyType.OffsetFromParentVert) }; } export function parseOutlineGroup(props: ObjectProps) { assertPropertySet(props, 0x00060019); const childLevel = props.getU8(PropertyType.OutlineElementChildLevel); if (childLevel === undefined) throw new Error("outline group has no child level"); return { children: props.objectRefs(PropertyType.ElementChildNodes), childLevel }; } export function parseOutlineElementNode(props: ObjectProps) { assertPropertySet(props, 0x0006000d); const childLevel = props.getU8(PropertyType.OutlineElementChildLevel); if (childLevel === undefined) throw new Error("outline element has no child element level"); return { children: props.objectRefs(PropertyType.ElementChildNodes), childLevel, contents: props.objectRefs(PropertyType.ContentChildNodes), listContents: props.objectRefs(PropertyType.ListNodes), listSpacing: props.getF32(PropertyType.ListSpacingMu) }; } export function parseRichTextNode(props: ObjectProps) { assertPropertySet(props, 0x0006000e); const text = props.getString(PropertyType.RichEditTextUnicode) ?? props.getAscii(PropertyType.TextExtendedAscii); return { text, textRunFormatting: props.objectRefs(PropertyType.TextRunFormatting), textRunIndices: props.getVecU32(PropertyType.TextRunIndex) ?? [], textRunDataObject: props.objectRefs(PropertyType.TextRunDataObject), paragraphStyle: props.objectRef(PropertyType.ParagraphStyle), paragraphSpaceBefore: props.getF32(PropertyType.ParagraphSpaceBefore) ?? 0, paragraphSpaceAfter: props.getF32(PropertyType.ParagraphSpaceAfter) ?? 0, paragraphLineSpacingExact: props.getF32( PropertyType.ParagraphLineSpacingExact ), paragraphAlignment: parseParagraphAlignment(props) ?? ParagraphAlignment.Left, layoutAlignmentInParent: parseLayoutAlignment( props, PropertyType.LayoutAlignmentInParent ), layoutAlignmentSelf: parseLayoutAlignment( props, PropertyType.LayoutAlignmentSelf ), noteTags: parseNoteTagContainers(props) }; } export type ParagraphStyleData = { charset?: Charset; bold: boolean; italic: boolean; underline: boolean; strikethrough: boolean; superscript: boolean; subscript: boolean; font?: string; fontSize?: number; fontColor?: ColorRef; highlight?: ColorRef; nextStyle?: string; styleId?: string; paragraphAlignment?: ParagraphAlignment; paragraphSpaceBefore?: number; paragraphSpaceAfter?: number; paragraphLineSpacingExact?: number; languageCode?: number; mathFormatting: boolean; hyperlink: boolean; hyperlinkProtected: boolean; hidden: boolean; textRunIsEmbeddedObject: boolean; textRunObjectType?: number; }; const FALLBACK_STYLE: ParagraphStyleData = { charset: Charset.Ansi, bold: false, italic: false, underline: false, strikethrough: false, superscript: false, subscript: false, font: undefined, fontSize: undefined, fontColor: undefined, highlight: undefined, nextStyle: undefined, styleId: undefined, paragraphAlignment: undefined, paragraphSpaceBefore: undefined, paragraphSpaceAfter: undefined, paragraphLineSpacingExact: undefined, languageCode: 1033, mathFormatting: false, hyperlink: false, hyperlinkProtected: false, hidden: false, textRunIsEmbeddedObject: false, textRunObjectType: undefined }; export function parseParagraphStyleObject(props: ObjectProps): ParagraphStyleData { if (props.jcid.id !== 0x0012004d) { return FALLBACK_STYLE; } return { charset: parseCharset(props, PropertyType.Charset), bold: props.getBoolOrDefault(PropertyType.Bold, false), italic: props.getBoolOrDefault(PropertyType.Italic, false), underline: props.getBoolOrDefault(PropertyType.Underline, false), strikethrough: props.getBoolOrDefault(PropertyType.Strikethrough, false), superscript: props.getBoolOrDefault(PropertyType.Superscript, false), subscript: props.getBoolOrDefault(PropertyType.Subscript, false), font: props.getString(PropertyType.Font), fontSize: props.getU16(PropertyType.FontSize), fontColor: parseColorRef(props, PropertyType.FontColor), highlight: parseColorRef(props, PropertyType.Highlight), nextStyle: props.getString(PropertyType.NextStyle), styleId: props.getString(PropertyType.ParagraphStyleId), paragraphAlignment: parseParagraphAlignment(props), paragraphSpaceBefore: props.getF32(PropertyType.ParagraphSpaceBefore), paragraphSpaceAfter: props.getF32(PropertyType.ParagraphSpaceAfter), paragraphLineSpacingExact: props.getF32( PropertyType.ParagraphLineSpacingExact ), languageCode: props.getU32(PropertyType.LanguageId), mathFormatting: props.getBoolOrDefault(PropertyType.MathFormatting, false), hyperlink: props.getBoolOrDefault(PropertyType.Hyperlink, false), hyperlinkProtected: props.getBoolOrDefault( PropertyType.HyperlinkProtected, false ), hidden: props.getBoolOrDefault(PropertyType.Hidden, false), textRunIsEmbeddedObject: props.getBoolOrDefault( PropertyType.TextRunIsEmbeddedObject, false ), textRunObjectType: props.getU32(PropertyType.EmbeddedObjectType) }; } export type NoteTagContainerData = { definition?: string; completedAtMs?: number; itemStatus: ActionItemStatus; }; export function parseNoteTagContainers( props: ObjectProps ): NoteTagContainerData[] { const value = props.propertyValues(PropertyType.NoteTags); if (!value) return []; return value.propertySets.map((set) => { const sub = props.subObject(PropertyType.NoteTags, value.propertyId, set); const itemStatus = parseActionItemStatus(sub); if (!itemStatus) throw new Error("note tag container has no item status"); const completedAt = sub.getU32(PropertyType.NoteTagCompleted); return { definition: sub.objectRef(PropertyType.NoteTagDefinitionOid), completedAtMs: completedAt === undefined ? undefined : oneNoteTimeToMs(completedAt), itemStatus }; }); } export function parseNoteTagSharedDefinitionContainer(props: ObjectProps) { assertPropertySet(props, 0x00120043); return { label: props.getString(PropertyType.NoteTagLabel) ?? "", status: parseNoteTagPropertyStatus(props) ?? { hasLabel: false, hasFontColor: false, hasHighlightColor: false, hasIcon: false, dueToday: false, dueTomorrow: false, dueThisWeek: false, dueNextWeek: false, dueLater: false, dueCustom: false }, shape: parseNoteTagShape(props.getU16(PropertyType.NoteTagShape) ?? 0), highlightColor: parseColorRef(props, PropertyType.NoteTagHighlightColor), textColor: parseColorRef(props, PropertyType.NoteTagTextColor), actionItemType: parseActionItemType(props) ?? { type: "unknown" as const } }; } export function parseNumberListNode(props: ObjectProps) { assertPropertySet(props, 0x00060012); const listFormat = props .getVecU16(PropertyType.NumberListFormat) ?.slice(1) .map((c) => String.fromCharCode(c)); if (!listFormat) throw new Error("number list has no list format"); return { listFont: props.getString(PropertyType.ListFont), listRestart: props.getU32(PropertyType.ListRestart), listFormat, bold: props.getBoolOrDefault(PropertyType.Bold, false), italic: props.getBoolOrDefault(PropertyType.Italic, false), font: props.getString(PropertyType.Font), fontSize: props.getU16(PropertyType.FontSize), fontColor: parseColorRef(props, PropertyType.FontColor) }; } export function parseTableNode(props: ObjectProps) { assertPropertySet(props, 0x00060022); const rows = props.objectRefs(PropertyType.ElementChildNodes); if (!rows.length) throw new Error("table has no rows"); const rowCount = props.getU32(PropertyType.RowCount); if (rowCount === undefined) throw new Error("table has no row count"); const colCount = props.getU32(PropertyType.ColumnCount); if (colCount === undefined) throw new Error("table has no col count"); return { rows, rowCount, colCount, colsLocked: props.getVec(PropertyType.TableColumnsLocked)?.slice(1) ?? [], colWidths: readF32Slice( props.getVec(PropertyType.TableColumnWidths)?.slice(1) ?? new Uint8Array(0) ), bordersVisible: props.getBoolOrDefault( PropertyType.TableBordersVisible, true ), layoutAlignmentInParent: parseLayoutAlignment( props, PropertyType.LayoutAlignmentInParent ), layoutAlignmentSelf: parseLayoutAlignment( props, PropertyType.LayoutAlignmentSelf ), noteTags: parseNoteTagContainers(props) }; } export function parseTableRowNode(props: ObjectProps) { assertPropertySet(props, 0x00060023); const cells = props.objectRefs(PropertyType.ElementChildNodes); if (!cells.length) throw new Error("table row has no cells"); return { cells }; } export function parseTableCellNode(props: ObjectProps) { assertPropertySet(props, 0x00060024); const contents = props.objectRefs(PropertyType.ElementChildNodes); if (!contents.length) throw new Error("table cell has no contents"); const indents = parseOutlineIndentDistance(props); if (!indents) throw new Error("table cell has no outline indent distance"); return { contents, layoutMaxWidth: props.getF32(PropertyType.LayoutMaxWidth), indents, backgroundColor: parseColor(props, PropertyType.CellBackgroundColor) }; } export function parseImageNode(props: ObjectProps) { assertPropertySet(props, 0x00060011); return { pictureContainer: props.objectRef(PropertyType.PictureContainer), layoutMaxWidth: props.getF32(PropertyType.LayoutMaxWidth), layoutMaxHeight: props.getF32(PropertyType.LayoutMaxHeight), isLayoutSizeSetByUser: props.getBoolOrDefault( PropertyType.IsLayoutSizeSetByUser, false ), altText: props.getString(PropertyType.ImageAltText), layoutAlignmentInParent: parseLayoutAlignment( props, PropertyType.LayoutAlignmentInParent ), layoutAlignmentSelf: parseLayoutAlignment( props, PropertyType.LayoutAlignmentSelf ), imageFilename: props.getString(PropertyType.ImageFilename), displayedPageNumber: props.getU32(PropertyType.DisplayedPageNumber), text: props.getString(PropertyType.RichEditTextUnicode), pictureWidth: props.getF32(PropertyType.PictureWidth), pictureHeight: props.getF32(PropertyType.PictureHeight), hyperlinkUrl: props.getString(PropertyType.WzHyperlinkUrl), offsetHorizontal: props.getF32(PropertyType.OffsetFromParentHoriz), offsetVertical: props.getF32(PropertyType.OffsetFromParentVert), isBackground: props.getBoolOrDefault(PropertyType.IsBackground, false), noteTags: parseNoteTagContainers(props), iframe: props.objectRefs(PropertyType.ContentChildNodes) }; } export function parsePictureContainer(props: ObjectProps) { if (props.jcid.id !== 0x00080039 && props.jcid.id !== 0x0008003a) { throw new Error(`unexpected object type: 0x${props.jcid.id.toString(16).toUpperCase()}`); } return { data: props.fileData ?? new Uint8Array(0), extension: props.getString(PropertyType.PictureFileExtension) }; } export function parseEmbeddedFileNode(props: ObjectProps) { assertPropertySet(props, 0x00060035); return { pictureContainer: props.objectRef(PropertyType.PictureContainer), layoutMaxWidth: props.getF32(PropertyType.LayoutMaxWidth), layoutMaxHeight: props.getF32(PropertyType.LayoutMaxHeight), text: props.getString(PropertyType.RichEditTextUnicode), layoutAlignmentInParent: parseLayoutAlignment( props, PropertyType.LayoutAlignmentInParent ), layoutAlignmentSelf: parseLayoutAlignment( props, PropertyType.LayoutAlignmentSelf ), embeddedFileContainer: props.objectRef( PropertyType.EmbeddedFileContainer ), embeddedFileName: props.getString(PropertyType.EmbeddedFileName), sourcePath: props.getString(PropertyType.SourceFilepath), fileType: parseFileType(props), pictureWidth: props.getF32(PropertyType.PictureWidth), pictureHeight: props.getF32(PropertyType.PictureHeight), noteTags: parseNoteTagContainers(props), offsetHorizontal: props.getF32(PropertyType.OffsetFromParentHoriz), offsetVertical: props.getF32(PropertyType.OffsetFromParentVert) }; } export function parseEmbeddedFileContainer(props: ObjectProps) { assertPropertySet(props, 0x00080036); if (!props.fileData) throw new Error("embedded file container has no data"); return props.fileData; } export function parseInkContainer(props: ObjectProps) { assertPropertySet(props, 0x00060014); return { offsetHorizontal: props.getF32(PropertyType.OffsetFromParentHoriz), offsetVertical: props.getF32(PropertyType.OffsetFromParentVert), inkData: props.objectRef(PropertyType.InkData), children: props.objectRefs(PropertyType.ContentChildNodes), inkScalingX: props.getF32(PropertyType.InkScalingX), inkScalingY: props.getF32(PropertyType.InkScalingY) }; } export function parseInkDataNode(props: ObjectProps) { assertPropertySet(props, 0x0002003b); const boundingBox = props .getVecI32(PropertyType.InkBoundingBox) ?.filter((values, index, array) => array.length === 4) .slice(0, 4); return { strokes: props.objectRefs(PropertyType.InkStrokes), boundingBox: boundingBox && boundingBox.length === 4 ? { xMin: boundingBox[0], yMin: boundingBox[1], xMax: boundingBox[2], yMax: boundingBox[3] } : undefined }; } export function parseInkStrokeNode(props: ObjectProps) { assertPropertySet(props, 0x00020047); const pathData = props.getVec(PropertyType.InkPath); if (!pathData) throw new Error("ink stroke node has no ink path"); const path = decodeSignedMultiByte(pathData); const biasValue = props.getU8(PropertyType.InkBias) ?? 2; const properties = props.objectRef(PropertyType.InkStrokeProperties); if (!properties) throw new Error("ink stroke node has no ink stroke properties"); return { path, bias: biasValue, languageCode: props.getU32(PropertyType.LanguageId), properties }; } const X_DIMENSION_GUID = "598A6A8F-52C0-4BA0-93AF-AF357411A561"; const Y_DIMENSION_GUID = "B53F9F75-04E0-4498-A7EE-C30DBB5A9011"; export type InkDimension = { id: string; limitLower: number; limitUpper: number; }; export function parseStrokePropertiesNode(props: ObjectProps) { assertPropertySet(props, 0x00120048); const inkWidth = props.getF32(PropertyType.InkHeight); if (inkWidth === undefined) throw new Error("ink stroke properties has no height"); const inkHeight = props.getF32(PropertyType.InkWidth); if (inkHeight === undefined) throw new Error("ink stroke properties has no width"); const dimensions: InkDimension[] = []; const data = props.getVec(PropertyType.InkDimensions) ?? new Uint8Array(0); for (let i = 0; i + 32 <= data.length; i += 32) { const entry = data.subarray(i, i + 32); const view = new DataView(entry.buffer, entry.byteOffset, 32); dimensions.push({ id: GUID.fromBuffer(entry.slice(0, 16)).toString(), limitLower: view.getInt32(16, true), limitUpper: view.getInt32(20, true) }); } return { aliased: props.getBoolOrDefault(PropertyType.InkAntialised, false), fitToCurve: props.getBoolOrDefault(PropertyType.InkFitToCurve, false), ignorePressure: props.getBoolOrDefault( PropertyType.InkIgnorePressure, false ), penTip: props.getU8(PropertyType.InkPenTip), rasterOperation: props.getU8(PropertyType.InkRasterOperation), transparency: props.getU8(PropertyType.InkTransparency), inkHeight, inkWidth, color: props.getU32(PropertyType.InkColor), dimensions }; } export function parseEmbeddedInkContainer(props: ObjectProps) { return { spaceWidth: props.getF32(PropertyType.EmbeddedInkSpaceWidth), spaceHeight: props.getF32(PropertyType.EmbeddedInkSpaceHeight), startX: props.getF32(PropertyType.EmbeddedInkStartX), startY: props.getF32(PropertyType.EmbeddedInkStartY), height: props.getF32(PropertyType.EmbeddedInkHeight), width: props.getF32(PropertyType.EmbeddedInkWidth), offsetHoriz: props.getF32(PropertyType.EmbeddedInkOffsetHoriz), offsetVert: props.getF32(PropertyType.EmbeddedInkOffsetVert) }; } // Handwriting recognition (OCR) property sets. export function parseRecognizedTextChildren(props: ObjectProps): string[] { return props.objectRefs(PropertyType.RecognizedTextChildNodes); } export function parseRecognizedTextWord(props: ObjectProps) { const alternatives = decodeAlternatives( props.getVec(PropertyType.RecognizedText) ?? new Uint8Array(0) ); return { alternatives, languageId: props.getU16(PropertyType.RecognizedTextLanguageId) }; } function decodeAlternatives(data: Uint8Array): string[] { const units: number[] = []; for (let i = 0; i + 2 <= data.length; i += 2) { units.push(data[i] | (data[i + 1] << 8)); } const result: string[] = []; let current: number[] = []; for (const unit of units) { if (unit === 0) { if (current.length) result.push(String.fromCharCode(...current)); current = []; } else { current.push(unit); } } return result; } /** Decode a multi-byte encoded signed integer array per [MS-ISF]. */ export function decodeSignedMultiByte(input: Uint8Array): number[] { const values = decodeMultiByte(input); return values.map((value) => { const shifted = Math.floor(value / 2); return value & 0x1 ? -shifted : shifted; }); } function decodeMultiByte(input: Uint8Array): number[] { const { value: length, offset } = decodeUint(input); const count = Math.floor(length / 2); const output: number[] = []; let index = offset; for (let i = 0; i < count; ++i) { if (index >= input.length) break; const decoded = decodeUint(input.subarray(index)); index += decoded.offset; output.push(decoded.value); } return output; } function decodeUint(data: Uint8Array): { value: number; offset: number } { let value = 0; let count = 0; for (const byte of data) { const flag = (byte & 0x80) === 0x80; value |= (byte & 0x7f) << (count * 7); count += 1; if (!flag) break; } return { value, offset: count }; } export { X_DIMENSION_GUID, Y_DIMENSION_GUID };