znote 1.0.0

A lightweight desktop editor for Typst with live preview, built with Tauri
//! Word drawings: pictures in the text or at a fixed place on the page, and text boxes; and the
//! pages Word gets as a copy (designed pages, and the header and footer of the others), built from
//! the same fixed layout as a PowerPoint slide.

use std::fmt::Write as _;

use typst::foundations::Smart;
use typst::layout::Rect;
use typst_layout::Page;

use super::Parts;
use typst::layout::{Abs, Frame, FrameItem, Point};
use typst_render::RenderOptions;

use crate::export::layout::{
    Align, BACKGROUND_PIXELS_PER_POINT, Layers, Paragraph, Piece, Run, layers, lines, paragraphs,
    place,
};
use crate::export::math::{self, Equations};
use crate::export::{Media, escape};

pub const PICTURE: &str = "http://schemas.openxmlformats.org/drawingml/2006/picture";
pub const SHAPE: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
const EMU_PER_POINT: f64 = 12700.0;

pub fn emu(points: f64) -> i64 {
    (points * EMU_PER_POINT).round() as i64
}

pub fn twips(points: f64) -> i64 {
    (points * 20.0).round() as i64
}

fn graphic(id: usize, link: &str, cx: i64, cy: i64) -> String {
    format!(
        r#"<a:graphic><a:graphicData uri="{PICTURE}"><pic:pic><pic:nvPicPr><pic:cNvPr id="{id}" name="Picture {id}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{link}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic>"#
    )
}

fn described(alt: Option<&str>) -> String {
    alt.map(|alt| format!(r#" descr="{}""#, escape(alt)))
        .unwrap_or_default()
}

pub fn inline_picture(id: usize, link: &str, width: f64, height: f64, alt: Option<&str>) -> String {
    let (cx, cy) = (emu(width), emu(height));
    format!(
        r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="{cx}" cy="{cy}"/><wp:docPr id="{id}" name="Picture {id}"{}/><wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr>{}</wp:inline></w:drawing></w:r>"#,
        described(alt),
        graphic(id, link, cx, cy)
    )
}

/// The opening of a drawing pinned to the page at `area`, in front of the text or behind it.
fn anchor(id: usize, area: Rect, behind: bool, name: &str, alt: Option<&str>) -> String {
    format!(
        r#"<wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{}" behindDoc="{}" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>{}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>{}</wp:posOffset></wp:positionV><wp:extent cx="{}" cy="{}"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="{id}" name="{name} {id}"{}/><wp:cNvGraphicFramePr/>"#,
        id * 2 + 2,
        u8::from(behind),
        emu(area.min.x.to_pt()),
        emu(area.min.y.to_pt()),
        emu((area.max.x - area.min.x).to_pt()),
        emu((area.max.y - area.min.y).to_pt()),
        described(alt),
    )
}

pub fn anchored_picture(
    id: usize,
    link: &str,
    area: Rect,
    behind: bool,
    alt: Option<&str>,
) -> String {
    format!(
        r#"<w:r><w:drawing>{}{}</wp:anchor></w:drawing></w:r>"#,
        anchor(id, area, behind, "Picture", alt),
        graphic(
            id,
            link,
            emu((area.max.x - area.min.x).to_pt()),
            emu((area.max.y - area.min.y).to_pt())
        )
    )
}

/// A text box pinned to the page, holding `paragraphs` (`w:p` elements).
pub fn text_box(id: usize, area: Rect, wraps: bool, paragraphs: &str) -> String {
    format!(
        r#"<w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing>{}<a:graphic><a:graphicData uri="{SHAPE}"><wps:wsp><wps:cNvSpPr txBox="1"/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{}" cy="{}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></wps:spPr><wps:txbx><w:txbxContent>{paragraphs}</w:txbxContent></wps:txbx><wps:bodyPr rot="0" vert="horz" wrap="{}" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t" anchorCtr="0" upright="1"><a:noAutofit/></wps:bodyPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback/></mc:AlternateContent></w:r>"#,
        anchor(id, area, false, "Text", None),
        emu((area.max.x - area.min.x).to_pt()),
        emu((area.max.y - area.min.y).to_pt()),
        if wraps { "square" } else { "none" },
    )
}

pub fn run_properties(
    family: &str,
    size: f64,
    bold: bool,
    italic: bool,
    colour: [u8; 3],
) -> String {
    let family = escape(family);
    let [red, green, blue] = colour;
    let size = (size * 2.0).round() as i64;
    format!(
        r#"<w:rFonts w:ascii="{family}" w:hAnsi="{family}" w:eastAsia="{family}" w:cs="{family}"/>{}{}<w:color w:val="{red:02X}{green:02X}{blue:02X}"/><w:sz w:val="{size}"/><w:szCs w:val="{size}"/>"#,
        if bold { "<w:b/>" } else { "" },
        if italic { "<w:i/>" } else { "" },
    )
}

fn properties(run: &Run) -> String {
    let [red, green, blue, _] = run.colour;
    format!(
        "<w:rPr>{}</w:rPr>",
        run_properties(
            &run.family,
            run.size,
            run.bold,
            run.italic,
            [red, green, blue]
        )
    )
}

/// What a number in a header or footer stands for.
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Field {
    Page,
    SectionPages,
    Pages,
}

/// Text split where digits start and stop, so a page number can be told from what is around it.
pub fn segments(text: &str) -> Vec<&str> {
    let mut parts = Vec::new();
    let mut start = 0;
    let mut digits = None;
    for (at, character) in text.char_indices() {
        let digit = character.is_ascii_digit();
        if digits.is_some_and(|before| before != digit) {
            parts.push(&text[start..at]);
            start = at;
        }
        digits = Some(digit);
    }
    if start < text.len() {
        parts.push(&text[start..]);
    }
    parts
}

pub fn is_number(segment: &str) -> bool {
    !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit())
}

/// A text box paragraph's text, one entry per number in it.
pub fn numbers(paragraph: &Paragraph) -> Vec<String> {
    place(paragraph)
        .pieces
        .iter()
        .filter_map(|piece| match piece {
            Piece::Text(_, text) => Some(text.as_str()),
            Piece::Break => None,
        })
        .flat_map(segments)
        .filter(|segment| is_number(segment))
        .map(str::to_string)
        .collect()
}

/// The paragraph of a text box: `fields` says, per number in its text, which field it becomes.
fn paragraph(paragraph: &Paragraph, fields: &[Option<Field>]) -> String {
    let placed = place(paragraph);
    let align = match paragraph.align {
        Align::Left => "left",
        Align::Centre => "center",
        Align::Right => "right",
    };
    let line = if placed.wrapped {
        format!(r#" w:line="{}" w:lineRule="exact""#, twips(paragraph.pitch))
    } else {
        String::new()
    };
    let mut xml = format!(
        r#"<w:p><w:pPr><w:spacing w:before="0" w:after="0"{line}/><w:jc w:val="{align}"/></w:pPr>"#
    );
    let mut number = 0;
    for piece in &placed.pieces {
        let (run, text) = match piece {
            Piece::Break => {
                xml.push_str("<w:r><w:br/></w:r>");
                continue;
            }
            Piece::Text(run, text) => (run, text),
        };
        let properties = properties(run);
        for segment in segments(text) {
            let field = if is_number(segment) {
                number += 1;
                fields.get(number - 1).copied().flatten()
            } else {
                None
            };
            let shown = format!(
                r#"<w:r>{properties}<w:t xml:space="preserve">{}</w:t></w:r>"#,
                escape(segment)
            );
            match field {
                Some(field) => {
                    let instruction = match field {
                        Field::Page => "PAGE",
                        Field::SectionPages => "SECTIONPAGES",
                        Field::Pages => "NUMPAGES",
                    };
                    let _ = write!(
                        xml,
                        r#"<w:fldSimple w:instr=" {instruction} ">{shown}</w:fldSimple>"#
                    );
                }
                None => xml.push_str(&shown),
            }
        }
    }
    xml.push_str("</w:p>");
    xml
}

/// A page as Word copies it: anchors for its picture, images, text boxes and equations, to go
/// into one paragraph. `fields` gives, per text box, which of its numbers are fields.
pub fn copy(
    page: &Page,
    equations: &Equations,
    fields: &[Vec<Option<Field>>],
    parts: &mut Parts,
    links: &mut Vec<(&'static str, String)>,
) -> Result<String, String> {
    let (mut background, layers) = layers(page, equations)?;
    copy_layers(&mut background, layers, fields, parts, links)
}

pub fn copy_layers(
    background: &mut Page,
    layers: Layers,
    fields: &[Vec<Option<Field>>],
    parts: &mut Parts,
    links: &mut Vec<(&'static str, String)>,
) -> Result<String, String> {
    let mut xml = String::new();
    // A page that sets no fill is white in Word anyway; leave it transparent so nothing hides.
    if background.fill == Smart::Auto {
        background.fill = Smart::Custom(None);
    }
    let size = background.frame.size();
    let full = Rect::new(Point::zero(), size.to_point());
    if !blank(background) {
        let options = RenderOptions {
            pixel_per_pt: BACKGROUND_PIXELS_PER_POINT.into(),
            ..Default::default()
        };
        let bytes = typst_render::render(background, &options)
            .encode_png()
            .map_err(|error| format!("A picture could not be drawn: {error}"))?;
        let link = parts.picture(
            links,
            Media {
                bytes,
                extension: "png",
            },
        );
        let id = parts.id();
        xml.push_str(&anchored_picture(id, &link, full, true, None));
    }
    for picture in layers.pictures {
        let link = parts.picture(links, picture.media);
        let id = parts.id();
        xml.push_str(&anchored_picture(
            id,
            &link,
            picture.rect,
            false,
            picture.alt.as_deref(),
        ));
    }
    let empty = Vec::new();
    for (index, paragraph) in paragraphs(lines(layers.runs)).iter().enumerate() {
        for run in paragraph.lines.iter().flatten() {
            parts.font(&run.family, run.bold, run.italic, &run.font);
        }
        let placed = place(paragraph);
        // A line that just fits in Typst may not in Word (another font, other measurements): a
        // single line gets room to grow away from where it is aligned.
        let room = if placed.wrapped {
            0.0
        } else {
            placed.width * 0.5
        };
        let x = match paragraph.align {
            Align::Left => placed.x,
            Align::Centre => placed.x - room / 2.0,
            Align::Right => placed.x - room,
        };
        let area = Rect::new(
            Point::new(Abs::pt(x), Abs::pt(placed.top)),
            Point::new(
                Abs::pt(x + placed.width + room),
                Abs::pt(placed.top + placed.height),
            ),
        );
        let id = parts.id();
        xml.push_str(&text_box(
            id,
            area,
            placed.wrapped,
            &self::paragraph(paragraph, fields.get(index).unwrap_or(&empty)),
        ));
    }
    for formula in layers.formulas {
        let [red, green, blue, _] = formula.colour;
        let run = format!(
            r#"<w:rPr><w:rFonts w:ascii="Cambria Math" w:hAnsi="Cambria Math"/><w:color w:val="{red:02X}{green:02X}{blue:02X}"/><w:sz w:val="{0}"/><w:szCs w:val="{0}"/></w:rPr>"#,
            (formula.size * 2.0).round() as i64
        );
        let id = parts.id();
        xml.push_str(&text_box(
            id,
            formula.rect,
            false,
            &format!(
                r#"<w:p><w:pPr><w:spacing w:before="0" w:after="0"/><w:jc w:val="center"/></w:pPr><m:oMathPara>{}</m:oMathPara></w:p>"#,
                math::omml(&formula.math, &run)
            ),
        ));
    }
    Ok(xml)
}

fn blank(page: &Page) -> bool {
    page.fill.clone().unwrap_or(None).is_none() && !paints(&page.frame)
}

fn paints(frame: &Frame) -> bool {
    frame.items().any(|(_, item)| match item {
        FrameItem::Group(group) => paints(&group.frame),
        FrameItem::Tag(_) | FrameItem::Link(..) => false,
        _ => true,
    })
}