annotation.js

/**
 * Create a comment annotation
 * @name comment
 * @function
 * @memberof Recipe
 * @param {string} text - The text content
 * @param {number} x - The coordinate x
 * @param {number} y - The coordinate y
 * @param {Object} [options] - The options
 * @param {string} [options.title] - The title.
 * @param {string} [options.date] - The date.
 * @param {boolean} [options.open=false] - Open the annotation by default?
 * @param {boolean} [options.richText] - Display with rich text format, text will be transformed automatically, or you may pass in your own rich text starts with "<?xml..."
 * @param {'invisible'|'hidden'|'print'|'nozoom'|'norotate'|'noview'|'readonly'|'locked'|'togglenoview'} [options.flag] - The flag property
 */
exports.comment = function comment(text = '', x, y, options = {}) {
    this.annotationsToWrite.push({
        subtype: 'Text',
        pageNumber: this.pageNumber,
        args: { text, x, y, options: Object.assign({ icon: 'Comment' }, options) }
    });
    return this;
};

/**
 * Create an annotation
 * @name annot
 * @function
 * @memberof Recipe
 * @todo support for rich texst RC
 * @todo support for opacity CA
 * @param {number} x - The coordinate x
 * @param {number} y - The coordinate y
 * @param {string} subtype - The markup annotation type 'Text'|'FreeText'|'Line'|'Square'|'Circle'|'Polygon'|'PolyLine'|'Highlight'|'Underline'|'Squiggly'|'StrikeOut'|'Stamp'|'Caret'|'Ink'|'FileAttachment'|'Sound'
 * @param {Object} [options] - The options
 * @param {string} [options.title] - The title.
 * @param {boolean} [options.open=false] - Open the annotation by default?
 * @param {'invisible'|'hidden'|'print'|'nozoom'|'norotate'|'noview'|'readonly'|'locked'|'togglenoview'} [options.flag] - The flag property
 * @param {'Comment'|'Key'|'Note'|'Help'|'NewParagraph'|'Paragraph'|'Insert'} [options.icon] - The icon of annotation.
 * @param {number} [options.width] - Width
 * @param {number} [options.height] - Height
 */
exports.annot = function annot(x, y, subtype, options = { text: '', width: 0, height: 0 }) {
    const { text, width, height } = options;
    this.annotationsToWrite.push({
        subtype,
        args: { text, x, y, width, height, options },
        pageNumber: this.pageNumber
    });
    return this;
};

// TODO: allow non-markup annots to be associated with markup annotations
// Link, Popup, Movie, Widget, Screen, PrinterMark, TrapNet, Watermark, 3D
exports._attachNonMarkupAnnot = function _attachNonMarkupAnnot() {

};

exports._annot = function _annot(subtype, args = {}, pageNumber) {
    const { x, y, width, height, text, options } = args;
    this._startDictionary(pageNumber);
    const { rotate } = this.metadata[pageNumber];
    let { nx, ny } = this._calibrateCoordinateForAnnots(x, y, 0, 0, pageNumber);

    let nWidth = width;
    let nHeight = height;

    if (!options.followOriginalPageRotation) {
        switch (rotate) {
            case 90:
                nWidth = height;
                nHeight = width;
                nx = nx - nWidth;
                break;
            case 180:
                nx = nx - nWidth;
                ny = ny - nHeight;
                break;
            case 270:
                nWidth = height;
                nHeight = width;
                ny = ny - nHeight;
                break;
            default:
        }
    }

    const params = Object.assign({
        title: '',
        subject: '',
        date: new Date(),
        open: false,
        flag: '' // 'readonly'
    }, options);

    const ex = (nWidth) ? nWidth : 0;
    const ey = (nHeight) ? nHeight : 0;
    const position = [nx, ny, nx + ex, ny + ey];

    this.dictionaryContext
        .writeKey('Type')
        .writeNameValue('Annot')
        .writeKey('Subtype')
        .writeNameValue(subtype)
        .writeKey('L')
        .writeBooleanValue(true)
        .writeKey('Rect')
        .writeRectangleValue(position)
        .writeKey('Subj')
        .writeLiteralStringValue(params.subject)
        .writeKey('T')
        .writeLiteralStringValue(params.title || '')
        .writeKey('M')
        .writeLiteralStringValue(this.writer.createPDFDate(params.date).toString())
        .writeKey('Open')
        .writeBooleanValue(params.open)
        .writeKey('F')
        .writeNumberValue(getFlagBitNumberByName(params.flag));

    /**
     * Rich Text Strings
     * 12.7.3.4
     */
    if (text && options.richText) {
        const richText = (text.substring(0, 4) !== '<?xml') ? contentToRC(text) : text;
        const richTextContent = richText;
        this.dictionaryContext
            .writeKey('RC')
            .writeLiteralStringValue(richTextContent);
    } else
    if (text) {
        const textContent = text;
        this.dictionaryContext
            .writeKey('Contents')
            .writeLiteralStringValue(textContent);
    }

    let { border, color } = options;

    if (this._getTextMarkupAnnotationSubtype(subtype)) {
        this.dictionaryContext.writeKey('QuadPoints');
        const { _textHeight } = options;
        const annotHeight = height;
        const bx = nx;
        const by = ny + ((_textHeight) ? 0 : -annotHeight);
        const coordinates = [
            [bx, by + annotHeight],
            [bx + nWidth, by + annotHeight],
            [bx, by],
            [bx + nWidth, by],
        ];
        this.objectsContext.startArray();
        coordinates.forEach(coord => {
            coord.forEach(point => {
                this.objectsContext.writeNumber(Math.round(point));
            });
        });
        this.objectsContext
            .endArray()
            .endLine();

        border = border || 0;
        if (!color) {
            switch (subtype) {
                case 'Highlight':
                    color = [255, 255, 0];
                    break;
                case 'StrikeOut':
                    color = [255, 0, 0];
                    break;
                case 'Underline':
                    color = [0, 255, 0];
                    break;
                case 'Squiggly':
                    color = [0, 255, 0];
                    break;
                default:
                    color = [0, 0, 0];
                    break;
            }
        }
    }

    if (border != void(0)) {
        this.dictionaryContext.writeKey('Border');
        this.objectsContext
            .startArray()
            .writeNumber(0)
            .writeNumber(0)
            .writeNumber(border)
            .endArray()
            .endLine();
    }

    if (color) {
        const rgb = this._colorNumberToRGB(this._transformColor(color));
        this.dictionaryContext.writeKey('C');
        this.objectsContext
            .startArray()
            .writeNumber(rgb.r / 255)
            .writeNumber(rgb.g / 255)
            .writeNumber(rgb.b / 255)
            .endArray()
            .endLine();
    }

    /* Display Icon */
    if (params.icon) {
        this.dictionaryContext
            .writeKey('Name')
            .writeNameValue(params.icon);
    }
    this._endDictionary(pageNumber);
};

exports._writeAnnotations = function _writeAnnotations() {
    this.annotationsToWrite.forEach((annot) => {
        this._annot(annot.subtype, annot.args, annot.pageNumber);
    });
    this.annotations.forEach((pageAnnots, index) => {
        this._writeAnnotation(index);
    });
};

exports._writeAnnotation = function _writeAnnotation(pageIndex) {
    const pdfWriter = this.writer;
    const copyingContext = pdfWriter.createPDFCopyingContextForModifiedFile();
    const pageID = copyingContext.getSourceDocumentParser().getPageObjectID(pageIndex);
    const pageObject = copyingContext.getSourceDocumentParser().parsePage(pageIndex).getDictionary().toJSObject();
    const objectsContext = pdfWriter.getObjectsContext();

    objectsContext.startModifiedIndirectObject(pageID);
    const modifiedPageObject = pdfWriter.getObjectsContext().startDictionary();
    Object.getOwnPropertyNames(pageObject).forEach((element) => {
        const ignore = ['Annots'];
        if (!ignore.includes(element)) {
            modifiedPageObject.writeKey(element);
            copyingContext.copyDirectObjectAsIs(pageObject[element]);
        }
    });

    modifiedPageObject.writeKey('Annots');
    objectsContext.startArray();
    if (pageObject['Annots'] && pageObject['Annots'].toJSArray) {
        pageObject['Annots'].toJSArray().forEach((annot) => {
            objectsContext.writeIndirectObjectReference(annot.getObjectID());
        });
    }
    this.annotations[pageIndex].forEach((item) => {
        objectsContext.writeIndirectObjectReference(item);
    });

    objectsContext
        .endArray()
        .endLine()
        .endDictionary(modifiedPageObject)
        .endIndirectObject();
};

exports._startDictionary = function _startDictionary() {
    this.objectsContext = this.writer.getObjectsContext();
    this.dictionaryObject = this.objectsContext.startNewIndirectObject();
    this.dictionaryContext = this.objectsContext.startDictionary();
};

exports._endDictionary = function _endDictionary(pageNumber) {
    this.objectsContext
        .endDictionary(this.dictionaryContext)
        .endIndirectObject();
    const pageIndex = pageNumber - 1;
    this.annotations[pageIndex] = this.annotations[pageIndex] || [];
    this.annotations[pageIndex].push(this.dictionaryObject);
};

exports._getTextMarkupAnnotationSubtype = function _getTextMarkupAnnotationSubtype(subtype = '') {
    const matchedSubtype = this.textMarkupAnnotations.find(item => {
        return item.toLowerCase() == subtype.toLowerCase();
    });
    return matchedSubtype;
};

/**
 * Get Flag Bit by Name
 * @description 12.5.3 Annotation Flags
 * @param {string} name
 */
function getFlagBitNumberByName(name) {
    switch (name.toLowerCase()) {
        case 'invisible':
            return 1;
        case 'hidden':
            return 2;
        case 'print':
            return 4;
        case 'nozoom':
            return 8;
        case 'norotate':
            return 16;
        case 'noview':
            return 32;
        case 'readonly':
            return 64;
        case 'locked':
            return 128;
        case 'togglenoview':
            return 256;
            // 1.7+
            // case 'lockedcontents':
            //     return 512;
        default:
            return 0;
    }
}

/**
 * Text Strings to Rich Text Strings
 * @todo Fix display issue for ol/ul in richText
 * @param {string} content
 * @description Support XHTML Elements:  '<p>' | '<span>' | '<b>' | '<i>'
 * @description Support CSS2 Style: 'text-align' | 'vertical-align' | 'font-size' | 'font-style' | 'font-weight' | 'font-family' | 'font' | 'color' | 'text-decoration' | 'font-stretch'
 */
function contentToRC(content) {
    content = content.replace('&nbsp;', ' ');
    content = content.replace(/\r?\n|\r|\t/g, '');
    let richText =
        '<?xml version="1.0"?>' +
        '<body ' +
        'xmlns="http://www.w3.org/1999/xhtml"' +
        // 'xmlns:xga=\"http://www.xfa.org/schema/xfa-data/1.0/\" ' +
        // 'xfa:contentType=\"text/html\" ' +
        // 'xfa:APIVersion=\"Acrobat:8.0.0\" ' +
        // 'xfa:spec=\"2.4\" ' +
        '>' +
        content +
        '</body>';
    richText = richText
        .replace(/<li>/g, '<p> • ')
        .replace(/<(\/)li>/g, '</p>')
        .replace(/<(\/)p>/g, '</p><br/>');
    return richText;
}