/*
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 {
Content,
EmbeddedFile,
Image,
Ink,
InkStroke,
List,
Outline,
OutlineElement,
OutlineItem,
Page,
PageContent,
RichText,
Table,
TableCell
} from "../onenote/types";
import { ActionItemStatus, NoteTagShape } from "../one/property-set";
import {
ICON_ARROW_RIGHT,
ICON_AWARD,
ICON_BOOK,
ICON_BUBBLE,
ICON_CHECKBOX_COMPLETE,
ICON_CHECKBOX_EMPTY,
ICON_CHECK_MARK,
ICON_CIRCLE,
ICON_CONTACT,
ICON_EMAIL,
ICON_ERROR,
ICON_FILM,
ICON_FLAG,
ICON_HOME,
ICON_LIGHT_BULB,
ICON_LINK,
ICON_LOCK,
ICON_MUSIC,
ICON_PAPER,
ICON_PEN,
ICON_PERSON,
ICON_PHONE,
ICON_QUESTION_MARK,
ICON_SQUARE,
ICON_STAR
} from "./icons";
import { AttributeSet, StyleSet, px } from "./utils";
const COLOR_BLUE = "#4673b7";
const COLOR_GREEN = "#369950";
const COLOR_ORANGE = "#dba24d";
const COLOR_PINK = "#f78b9d";
const COLOR_RED = "#db5b4d";
const COLOR_YELLOW = "#ffd678";
const FORMAT_NUMBERED_LIST = "\ufffd";
const HYPERLINK_MARKER = "\ufddfHYPERLINK \"";
/**
* Resolves the binary data of an image or embedded file into an HTML string.
* Used by the importer to attach hashed attachments instead of writing files
* to disk.
*/
export type ResourceResolver = (
data: Uint8Array,
filename: string,
extension?: string,
meta?: { width?: number; height?: number; altText?: string }
) => string | Promise;
export type RenderOptions = {
resolveResource?: ResourceResolver;
};
export type RenderResult = {
/** Linearized HTML content with data-onenote-* position attributes. */
html: string;
/** SVG snapshot preserving the original visual layout (only for pages with ink). */
svgSnapshot?: string;
};
type NoteTagStyle = StyleSet;
class PageRenderer {
private inList = false;
private globalStyles = new Map();
private globalClasses = new Set();
private usedFilenames = new Set();
constructor(private readonly options: RenderOptions) {}
async renderPage(page: Page): Promise {
const titleText = page.titleText || "Untitled Page";
// Sort page contents spatially: top-to-bottom, then left-to-right.
const sorted = sortPageContents(page);
let content = "";
// Render title (always first, at its absolute position).
if (page.title) {
const title = page.title;
const x = title.offsetHorizontal;
const y = title.offsetVertical;
let titleField = `
`;
for (const outline of title.contents) {
titleField += await this.renderOutline(outline);
}
titleField += "
";
content += titleField;
}
// Render each content element with position metadata.
for (const item of sorted) {
const pos = getContentPosition(item);
const attrs = pos
? ` data-onenote-x="${pos.x}" data-onenote-y="${pos.y}" data-onenote-w="${pos.w}" data-onenote-h="${pos.h}"`
: "";
const rendered = await this.renderPageContent(item);
if (rendered) {
content += `
${rendered}
`;
}
}
// Generate SVG snapshot only when the page has ink elements.
const hasInk = page.contents.some(
(c) => c.type === "ink" || (c.type === "outline" && outlineHasInk(c.outline))
);
const svgSnapshot = hasInk ? renderSvgSnapshot(page) : undefined;
return {
html: renderPageTemplate(titleText, content, this.globalStyles),
svgSnapshot
};
}
private genClass(prefix: string): string {
let i = 0;
while (true) {
const className = `${prefix}-${i}`;
if (!this.globalClasses.has(className)) {
this.globalClasses.add(className);
return className;
}
i += 1;
}
}
private renderPageContent(
content: Page["contents"][number]
): Promise | string {
switch (content.type) {
case "outline":
return this.renderOutline(content.outline);
case "image":
return this.renderImage(content.image);
case "embeddedFile":
return this.renderEmbeddedFile(content.embeddedFile);
case "ink":
return this.renderInk(content.ink, undefined, false);
case "unknown":
return "";
}
}
private renderContent(content: Content): Promise | string {
switch (content.type) {
case "richText":
return this.renderRichText(content.richText);
case "table":
return this.renderTable(content.table);
case "image":
return this.renderImage(content.image);
case "embeddedFile":
return this.renderEmbeddedFile(content.embeddedFile);
case "ink":
return this.renderInk(content.ink, undefined, false);
case "unknown":
return "";
}
}
// -----------------------------------------------------------------------
// Rich text
// -----------------------------------------------------------------------
private renderRichText(text: RichText): string {
// Skip OneNote's auto-generated page metadata paragraphs — the title is
// already rendered via the positioned title overlay and dates are stored as
// note metadata (createdAt / updatedAt).
const styleId = text.paragraphStyle.styleId;
if (
styleId &&
!this.inList &&
(styleId === "PageDateTime" || styleId === "PageTitle")
) {
return "";
}
let content = "";
let style = this.parseParagraphStyles(text);
const noteTags = this.renderNoteTags(text.noteTags);
if (noteTags) {
content += noteTags.markup;
style.extend(noteTags.styles);
}
content += this.parseContent(text);
if (content.startsWith("http://") || content.startsWith("https://")) {
content = `${content}`;
}
if (styleId && !this.inList) {
return `<${styleId}${style.length > 0 ? ` style="${style}"` : ""}>${content}${styleId}>`;
} else if (style.length > 0) {
return `${content}`;
}
return content;
}
private parseContent(data: RichText): string {
if (data.embeddedObjects.length > 0) {
return data.embeddedObjects
.map((object) => {
switch (object.type) {
case "ink":
return this.renderInk(object.ink, object.boundingBox, true);
case "inkSpace":
return ``;
case "inkLineBreak":
return ' ';
}
})
.join("");
}
const indices = data.textRunIndices;
const styles = data.textRunFormatting;
let text = data.text;
if (text.length === 0) text = " ";
if (indices.length === 0) {
return fixNewlines(text);
}
// Split the text into parts specified by the text run indices.
const parts: string[] = [];
for (let i = indices.length - 1; i >= 0; --i) {
const index = indices[i];
parts.push(text.slice(index));
text = text.slice(0, index);
}
parts.push(text);
parts.reverse();
let inHyperlink = false;
const content = parts
.map((part, i) => {
const style = styles[i] ?? defaultParagraphStyling();
if (style.hyperlink) {
const rendered = this.renderHyperlink(part, style, inHyperlink);
inHyperlink = true;
return rendered;
} else {
inHyperlink = false;
const parsed = this.parseStyle(style);
if (parsed.length > 0) {
return `${part}`;
}
return part;
}
})
.join("");
return fixNewlines(content);
}
private renderHyperlink(
text: string,
style: RichText["textRunFormatting"][number],
inHyperlink: boolean
): string {
const styles = this.parseStyle(style);
if (text.startsWith(HYPERLINK_MARKER)) {
const url = text.slice(HYPERLINK_MARKER.length).replace(/"$/, "");
return ``;
} else if (inHyperlink) {
return `${text}`;
} else {
return `${text}`;
}
}
private parseParagraphStyles(text: RichText): StyleSet {
if (text.embeddedObjects.length > 0) {
return new StyleSet();
}
const styles = this.parseStyle(text.paragraphStyle);
if (text.textRunFormatting.length === 1) {
styles.extend(this.parseStyle(text.textRunFormatting[0]));
}
if (text.paragraphSpaceBefore > 0) {
styles.set("padding-top", px(text.paragraphSpaceBefore));
}
if (text.paragraphSpaceAfter > 0) {
styles.set("padding-bottom", px(text.paragraphSpaceAfter));
}
switch (text.paragraphAlignment) {
case 2: // Center
styles.set("text-align", "center");
break;
case 3: // Right
styles.set("text-align", "right");
break;
}
return styles;
}
private parseStyle(style: RichText["textRunFormatting"][number]): StyleSet {
return this.parseStyleRaw(style);
}
private parseStyleRaw(style: RichText["textRunFormatting"][number]): StyleSet {
const styles = new StyleSet();
if (style.bold) styles.set("font-weight", "bold");
if (style.italic) styles.set("font-style", "italic");
if (style.underline) styles.set("text-decoration", "underline");
if (style.superscript) styles.set("vertical-align", "super");
if (style.subscript) styles.set("vertical-align", "sub");
if (style.strikethrough) styles.set("text-decoration", "line-through");
if (style.font) styles.set("font-family", `${style.font},sans-serif`);
if (style.fontSize)
styles.set("font-size", `${style.fontSize / 2.0}pt`);
if (style.fontColor?.type === "manual") {
styles.set(
"color",
`rgb(${style.fontColor.r},${style.fontColor.g},${style.fontColor.b})`
);
}
if (style.highlight?.type === "manual") {
styles.set(
"background-color",
`rgb(${style.highlight.r},${style.highlight.g},${style.highlight.b})`
);
}
return styles;
}
// -----------------------------------------------------------------------
// Note tags
// -----------------------------------------------------------------------
private renderWithNoteTags(
noteTags: RichText["noteTags"],
content: string
): string {
const rendered = this.renderNoteTags(noteTags);
if (!rendered) return content;
return `
${rendered.markup}${content}
`;
}
private renderNoteTags(
noteTags: RichText["noteTags"]
): { markup: string; styles: StyleSet } | undefined {
if (noteTags.length === 0) return undefined;
let markup = "";
const styles = new StyleSet();
for (const noteTag of noteTags) {
const definition = noteTag.definition;
if (!definition) continue;
if (definition.highlightColor?.type === "manual") {
styles.set(
"background-color",
`rgb(${definition.highlightColor.r},${definition.highlightColor.g},${definition.highlightColor.b})`
);
}
if (definition.textColor?.type === "manual") {
styles.set(
"color",
`rgb(${definition.textColor.r},${definition.textColor.g},${definition.textColor.b})`
);
}
if (definition.shape !== 0) {
// Skip checkbox note tags (shapes 1-12) — the checklist
structure
// handles the visual representation.
if (definition.shape >= 1 && definition.shape <= 12) continue;
const emoji = noteTagEmoji(definition.shape);
if (emoji) {
markup += emoji + " ";
}
}
}
return { markup, styles };
}
private hasNoteTag(element: OutlineElement): boolean {
return element.contents.some(
(content) =>
content.type === "richText" && content.richText.noteTags.length > 0
);
}
private noteTagIcon(
shape: NoteTagShape,
status: ActionItemStatus
): { icon: string; style: StyleSet } {
let style = new StyleSet();
switch (shape) {
case 0: // NoIcon
return { icon: "", style };
case 1: return this.iconCheckbox(status, style, COLOR_GREEN);
case 2: return this.iconCheckbox(status, style, COLOR_YELLOW);
case 3: return this.iconCheckbox(status, style, COLOR_BLUE);
case 4: return this.iconCheckboxWith(status, style, COLOR_GREEN, ICON_STAR);
case 5: return this.iconCheckboxWith(status, style, COLOR_YELLOW, ICON_STAR);
case 6: return this.iconCheckboxWith(status, style, COLOR_BLUE, ICON_STAR);
case 7: return this.iconCheckboxWith(status, style, COLOR_GREEN, '!');
case 8: return this.iconCheckboxWith(status, style, COLOR_YELLOW, '!');
case 9: return this.iconCheckboxWith(status, style, COLOR_BLUE, '!');
case 10: return this.iconCheckboxWith(status, style, COLOR_GREEN, ICON_ARROW_RIGHT);
case 11: return this.iconCheckboxWith(status, style, COLOR_YELLOW, ICON_ARROW_RIGHT);
case 12: return this.iconCheckboxWith(status, style, COLOR_BLUE, ICON_ARROW_RIGHT);
case 13: {
style.set("fill", COLOR_YELLOW);
return { icon: ICON_STAR, style: this.iconStyle("normal", style) };
}
case 14: // BlueFollowUpFlag
case 16: // BlueRightArrow
case 18: // Meeting
case 19: // TimeSensitive
case 22: // Pushpin
case 25: // SmilingFace
case 27: // YellowKey
return { icon: "", style };
case 15: return { icon: ICON_QUESTION_MARK, style: this.iconStyle("normal", style) };
case 17: return { icon: ICON_ERROR, style: this.iconStyle("normal", style) };
case 20: return { icon: ICON_PHONE, style: this.iconStyle("normal", style) };
case 21: return { icon: ICON_LIGHT_BULB, style: this.iconStyle("normal", style) };
case 23: return { icon: ICON_HOME, style: this.iconStyle("normal", style) };
case 24: return { icon: ICON_BUBBLE, style: this.iconStyle("normal", style) };
case 26: return { icon: ICON_AWARD, style: this.iconStyle("normal", style) };
case 28: return this.iconCheckboxWith(status, style, COLOR_BLUE, '1');
case 30: return this.iconCheckboxWith(status, style, COLOR_BLUE, '2');
case 32: return this.iconCheckboxWith(status, style, COLOR_BLUE, '3');
case 35: return this.iconCheckmark(style, COLOR_BLUE);
case 36: return this.iconCircle(style, COLOR_BLUE);
case 48: return this.iconCheckboxWith(status, style, COLOR_GREEN, '1');
case 50: return this.iconCheckboxWith(status, style, COLOR_GREEN, '2');
case 52: return this.iconCheckboxWith(status, style, COLOR_GREEN, '3');
case 55: return this.iconCheckmark(style, COLOR_GREEN);
case 56: return this.iconCircle(style, COLOR_GREEN);
case 69: return this.iconCheckboxWith(status, style, COLOR_YELLOW, '1');
case 71: return this.iconCheckboxWith(status, style, COLOR_YELLOW, '2');
case 73: return this.iconCheckboxWith(status, style, COLOR_YELLOW, '3');
case 76: return this.iconCheckmark(style, COLOR_YELLOW);
case 77: return this.iconCircle(style, COLOR_YELLOW);
case 89: // FollowUpTodayFlag
case 90: // FollowUpTomorrowFlag
case 91: // FollowUpThisWeekFlag
case 92: // FollowUpNextWeekFlag
case 93: // NoFollowUpDateFlag
return this.iconCheckboxWith(status, style, COLOR_BLUE, ICON_FLAG);
case 94: return this.iconCheckboxWith(status, style, COLOR_BLUE, ICON_PERSON);
case 95: return this.iconCheckboxWith(status, style, COLOR_YELLOW, ICON_PERSON);
case 96: return this.iconCheckboxWith(status, style, COLOR_GREEN, ICON_PERSON);
case 97: return this.iconCheckboxWith(status, style, COLOR_BLUE, ICON_FLAG);
case 98: return this.iconCheckboxWith(status, style, COLOR_RED, ICON_FLAG);
case 99: return this.iconCheckboxWith(status, style, COLOR_GREEN, ICON_FLAG);
case 100: return this.iconSquare(style, COLOR_RED);
case 101: return this.iconSquare(style, COLOR_YELLOW);
case 102: return this.iconSquare(style, COLOR_BLUE);
case 103: return this.iconSquare(style, COLOR_GREEN);
case 104: return this.iconSquare(style, COLOR_ORANGE);
case 105: return this.iconSquare(style, COLOR_PINK);
case 106: return { icon: ICON_EMAIL, style: this.iconStyle("normal", style) };
case 107: // ClosedEnvelope
case 108: // OpenEnvelope
case 109: // MobilePhone
case 110: // TelephoneWithClock
case 111: // QuestionBalloon
case 112: // PaperClip
case 113: // FrowningFace
case 114: // InstantMessagingContactPerson
case 115: // PersonWithExclamationMark
case 116: // TwoPeople
case 117: // ReminderBell
return { icon: "", style };
case 118: return { icon: ICON_CONTACT, style: this.iconStyle("normal", style) };
case 119: // RoseOnAStem
case 120: // CalendarDateWithClock
return { icon: "", style };
case 121: return { icon: ICON_MUSIC, style: this.iconStyle("normal", style) };
case 122: return { icon: ICON_FILM, style: this.iconStyle("normal", style) };
case 123: // QuotationMark
case 124: // Globe
return { icon: "", style };
case 125: return { icon: ICON_LINK, style: this.iconStyle("normal", style) };
case 126: // Laptop
case 127: // Plane
case 128: // Car
case 129: // Binoculars
case 130: // PresentationSlide
return { icon: "", style };
case 131: return { icon: ICON_LOCK, style: this.iconStyle("normal", style) };
case 132: return { icon: ICON_BOOK, style: this.iconStyle("normal", style) };
case 133: // NotebookWithClock
return { icon: "", style };
case 134: return { icon: ICON_PAPER, style: this.iconStyle("normal", style) };
case 135: // Research
return { icon: "", style };
case 136: return { icon: ICON_PEN, style: this.iconStyle("normal", style) };
default:
return { icon: "", style };
}
}
private iconCheckbox(
status: ActionItemStatus,
style: StyleSet,
color: string
): { icon: string; style: StyleSet } {
style.set("fill", color);
const icon = status.completed ? ICON_CHECKBOX_COMPLETE : ICON_CHECKBOX_EMPTY;
return { icon, style: this.iconStyle("large", style) };
}
private iconCheckboxWith(
status: ActionItemStatus,
style: StyleSet,
color: string,
secondaryIcon: string
): { icon: string; style: StyleSet } {
style.set("fill", color);
const icon = `${
status.completed ? ICON_CHECKBOX_COMPLETE : ICON_CHECKBOX_EMPTY
}${secondaryIcon}`;
return { icon, style: this.iconStyle("large", style) };
}
private iconCheckmark(
style: StyleSet,
color: string
): { icon: string; style: StyleSet } {
style.set("fill", color);
return { icon: ICON_CHECK_MARK, style: this.iconStyle("large", style) };
}
private iconCircle(
style: StyleSet,
color: string
): { icon: string; style: StyleSet } {
style.set("fill", color);
return { icon: ICON_CIRCLE, style: this.iconStyle("normal", style) };
}
private iconSquare(
style: StyleSet,
color: string
): { icon: string; style: StyleSet } {
style.set("fill", color);
return { icon: ICON_SQUARE, style: this.iconStyle("large", style) };
}
private iconStyle(
size: "normal" | "large",
style: StyleSet
): StyleSet {
if (size === "normal") {
style.set("height", "16px");
style.set("width", "16px");
} else {
style.set("height", "20px");
style.set("width", "20px");
}
if (!this.inList) {
style.set("left", size === "normal" ? "-23px" : "-25px");
} else {
style.set("left", size === "normal" ? "-38px" : "-40px");
}
return style;
}
// -----------------------------------------------------------------------
// Outline
// -----------------------------------------------------------------------
private async renderOutline(outline: Outline): Promise {
const attrs = new AttributeSet();
const styles = new StyleSet();
attrs.set("class", "container-outline");
if (outline.layoutMaxWidth !== undefined) {
const outlineWidth = outline.isLayoutSizeSetByUser
? outline.layoutMaxWidth
: Math.max(outline.layoutMaxWidth, 13.0);
styles.set("max-width", px(outlineWidth));
}
if (
outline.offsetHorizontal !== undefined ||
outline.offsetVertical !== undefined
) {
styles.set("position", "absolute");
}
if (outline.offsetHorizontal !== undefined) {
styles.set("left", px(outline.offsetHorizontal));
}
if (outline.offsetVertical !== undefined) {
styles.set("top", px(outline.offsetVertical));
}
if (styles.length > 0) attrs.set("style", styles.toString());
let contents = `