diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index 22045e111..2dd7751aa 100644 --- a/l10n/en-US/viewer.properties +++ b/l10n/en-US/viewer.properties @@ -226,6 +226,10 @@ invalid_file_error=Invalid or corrupted PDF file. missing_file_error=Missing PDF file. unexpected_response_error=Unexpected server response. +# LOCALIZATION NOTE (annotation_date_string): "{{date}}" and "{{time}}" will be +# replaced by the modification date, and time, of the annotation. +annotation_date_string={{date}}, {{time}} + # LOCALIZATION NOTE (text_annotation_type.alt): This is used as a tooltip. # "{{type}}" will be replaced with an annotation type from a list defined in # the PDF spec (32000-1:2008 Table 169 – Annotation types). diff --git a/l10n/nl/viewer.properties b/l10n/nl/viewer.properties index 7422f8492..c62110a46 100644 --- a/l10n/nl/viewer.properties +++ b/l10n/nl/viewer.properties @@ -226,6 +226,10 @@ invalid_file_error=Ongeldig of beschadigd PDF-bestand. missing_file_error=PDF-bestand ontbreekt. unexpected_response_error=Onverwacht serverantwoord. +# LOCALIZATION NOTE (annotation_date_string): "{{date}}" and "{{time}}" will be +# replaced by the modification date, and time, of the annotation. +annotation_date_string={{date}}, {{time}} + # LOCALIZATION NOTE (text_annotation_type.alt): This is used as a tooltip. # "{{type}}" will be replaced with an annotation type from a list defined in # the PDF spec (32000-1:2008 Table 169 – Annotation types). diff --git a/src/core/annotation.js b/src/core/annotation.js index 742adaf43..a1491afe8 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -16,7 +16,7 @@ import { AnnotationBorderStyleType, AnnotationFieldFlag, AnnotationFlag, - AnnotationType, OPS, stringToBytes, stringToPDFString, Util, warn + AnnotationType, isString, OPS, stringToBytes, stringToPDFString, Util, warn } from '../shared/util'; import { Catalog, FileSpec, ObjectLoader } from './obj'; import { Dict, isDict, isName, isRef, isStream } from './primitives'; @@ -176,6 +176,8 @@ class Annotation { constructor(params) { let dict = params.dict; + this.setCreationDate(dict.get('CreationDate')); + this.setModificationDate(dict.get('M')); this.setFlags(dict.get('F')); this.setRectangle(dict.getArray('Rect')); this.setColor(dict.getArray('C')); @@ -187,8 +189,10 @@ class Annotation { annotationFlags: this.flags, borderStyle: this.borderStyle, color: this.color, + creationDate: this.creationDate, hasAppearance: !!this.appearance, id: params.id, + modificationDate: this.modificationDate, rect: this.rectangle, subtype: params.subtype, }; @@ -239,6 +243,31 @@ class Annotation { return this._isPrintable(this.flags); } + /** + * Set the creation date. + * + * @public + * @memberof Annotation + * @param {string} creationDate - PDF date string that indicates when the + * annotation was originally created + */ + setCreationDate(creationDate) { + this.creationDate = isString(creationDate) ? creationDate : null; + } + + /** + * Set the modification date. + * + * @public + * @memberof Annotation + * @param {string} modificationDate - PDF date string that indicates when the + * annotation was last modified + */ + setModificationDate(modificationDate) { + this.modificationDate = isString(modificationDate) ? + modificationDate : null; + } + /** * Set the flags. * @@ -947,6 +976,20 @@ class PopupAnnotation extends Annotation { this.data.title = stringToPDFString(parentItem.get('T') || ''); this.data.contents = stringToPDFString(parentItem.get('Contents') || ''); + if (!parentItem.has('CreationDate')) { + this.data.creationDate = null; + } else { + this.setCreationDate(parentItem.get('CreationDate')); + this.data.creationDate = this.creationDate; + } + + if (!parentItem.has('M')) { + this.data.modificationDate = null; + } else { + this.setModificationDate(parentItem.get('M')); + this.data.modificationDate = this.modificationDate; + } + if (!parentItem.has('C')) { // Fall back to the default background color. this.data.color = null; diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index ad6f14e73..6997a51df 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -14,7 +14,8 @@ */ import { - addLinkAttributes, DOMSVGFactory, getFilenameFromUrl, LinkTarget + addLinkAttributes, DOMSVGFactory, getFilenameFromUrl, LinkTarget, + PDFDateString } from './display_utils'; import { AnnotationBorderStyleType, AnnotationType, stringToPDFString, unreachable, @@ -251,6 +252,7 @@ class AnnotationElement { trigger, color: data.color, title: data.title, + modificationDate: data.modificationDate, contents: data.contents, hideWrapper: true, }); @@ -664,6 +666,7 @@ class PopupAnnotationElement extends AnnotationElement { trigger: parentElement, color: this.data.color, title: this.data.title, + modificationDate: this.data.modificationDate, contents: this.data.contents, }); @@ -686,6 +689,7 @@ class PopupElement { this.trigger = parameters.trigger; this.color = parameters.color; this.title = parameters.title; + this.modificationDate = parameters.modificationDate; this.contents = parameters.contents; this.hideWrapper = parameters.hideWrapper || false; @@ -724,9 +728,27 @@ class PopupElement { popup.style.backgroundColor = Util.makeCssRgb(r | 0, g | 0, b | 0); } - let contents = this._formatContents(this.contents); let title = document.createElement('h1'); title.textContent = this.title; + popup.appendChild(title); + + // The modification date is shown in the popup instead of the creation + // date if it is available and can be parsed correctly, which is + // consistent with other viewers such as Adobe Acrobat. + const dateObject = PDFDateString.toDateObject(this.modificationDate); + if (dateObject) { + const modificationDate = document.createElement('span'); + modificationDate.textContent = '{{date}}, {{time}}'; + modificationDate.dataset.l10nId = 'annotation_date_string'; + modificationDate.dataset.l10nArgs = JSON.stringify({ + date: dateObject.toLocaleDateString(), + time: dateObject.toLocaleTimeString(), + }); + popup.appendChild(modificationDate); + } + + let contents = this._formatContents(this.contents); + popup.appendChild(contents); // Attach the event listeners to the trigger element. this.trigger.addEventListener('click', this._toggle.bind(this)); @@ -734,8 +756,6 @@ class PopupElement { this.trigger.addEventListener('mouseout', this._hide.bind(this, false)); popup.addEventListener('click', this._hide.bind(this, true)); - popup.appendChild(title); - popup.appendChild(contents); wrapper.appendChild(popup); return wrapper; } diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 2d1655681..d4def30e8 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -15,7 +15,7 @@ /* eslint no-var: error */ import { - assert, CMapCompressionType, removeNullCharacters, stringToBytes, + assert, CMapCompressionType, isString, removeNullCharacters, stringToBytes, unreachable, URL, Util, warn } from '../shared/util'; @@ -491,6 +491,91 @@ function releaseImageResources(img) { img.removeAttribute('src'); } +let pdfDateStringRegex; + +class PDFDateString { + /** + * Convert a PDF date string to a JavaScript `Date` object. + * + * The PDF date string format is described in section 7.9.4 of the official + * PDF 32000-1:2008 specification. However, in the PDF 1.7 reference (sixth + * edition) Adobe describes the same format including a trailing apostrophe. + * This syntax in incorrect, but Adobe Acrobat creates PDF files that contain + * them. We ignore all apostrophes as they are not necessary for date parsing. + * + * Moreover, Adobe Acrobat doesn't handle changing the date to universal time + * and doesn't use the user's time zone (effectively ignoring the HH' and mm' + * parts of the date string). + * + * @param {string} input + * @return {Date|null} + */ + static toDateObject(input) { + if (!input || !isString(input)) { + return null; + } + + // Lazily initialize the regular expression. + if (!pdfDateStringRegex) { + pdfDateStringRegex = new RegExp( + '^D:' + // Prefix (required) + '(\\d{4})' + // Year (required) + '(\\d{2})?' + // Month (optional) + '(\\d{2})?' + // Day (optional) + '(\\d{2})?' + // Hour (optional) + '(\\d{2})?' + // Minute (optional) + '(\\d{2})?' + // Second (optional) + '([Z|+|-])?' + // Universal time relation (optional) + '(\\d{2})?' + // Offset hour (optional) + '\'?' + // Splitting apostrophe (optional) + '(\\d{2})?' + // Offset minute (optional) + '\'?' // Trailing apostrophe (optional) + ); + } + + // Optional fields that don't satisfy the requirements from the regular + // expression (such as incorrect digit counts or numbers that are out of + // range) will fall back the defaults from the specification. + const matches = pdfDateStringRegex.exec(input); + if (!matches) { + return null; + } + + // JavaScript's `Date` object expects the month to be between 0 and 11 + // instead of 1 and 12, so we have to correct for that. + const year = parseInt(matches[1], 10); + let month = parseInt(matches[2], 10); + month = (month >= 1 && month <= 12) ? month - 1 : 0; + let day = parseInt(matches[3], 10); + day = (day >= 1 && day <= 31) ? day : 1; + let hour = parseInt(matches[4], 10); + hour = (hour >= 0 && hour <= 23) ? hour : 0; + let minute = parseInt(matches[5], 10); + minute = (minute >= 0 && minute <= 59) ? minute : 0; + let second = parseInt(matches[6], 10); + second = (second >= 0 && second <= 59) ? second : 0; + const universalTimeRelation = matches[7] || 'Z'; + let offsetHour = parseInt(matches[8], 10); + offsetHour = (offsetHour >= 0 && offsetHour <= 23) ? offsetHour : 0; + let offsetMinute = parseInt(matches[9], 10) || 0; + offsetMinute = (offsetMinute >= 0 && offsetMinute <= 59) ? offsetMinute : 0; + + // Universal time relation 'Z' means that the local time is equal to the + // universal time, whereas the relations '+'/'-' indicate that the local + // time is later respectively earlier than the universal time. Every date + // is normalized to universal time. + if (universalTimeRelation === '-') { + hour += offsetHour; + minute += offsetMinute; + } else if (universalTimeRelation === '+') { + hour -= offsetHour; + minute -= offsetMinute; + } + + return new Date(Date.UTC(year, month, day, hour, minute, second)); + } +} + export { PageViewport, RenderingCancelledException, @@ -508,4 +593,5 @@ export { loadScript, deprecated, releaseImageResources, + PDFDateString, }; diff --git a/src/pdf.js b/src/pdf.js index 32895bae3..4ccf2c1a7 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -114,6 +114,7 @@ exports.getFilenameFromUrl = pdfjsDisplayDisplayUtils.getFilenameFromUrl; exports.LinkTarget = pdfjsDisplayDisplayUtils.LinkTarget; exports.addLinkAttributes = pdfjsDisplayDisplayUtils.addLinkAttributes; exports.loadScript = pdfjsDisplayDisplayUtils.loadScript; +exports.PDFDateString = pdfjsDisplayDisplayUtils.PDFDateString; exports.GlobalWorkerOptions = pdfjsDisplayWorkerOptions.GlobalWorkerOptions; exports.apiCompatibilityParams = pdfjsDisplayAPICompatibility.apiCompatibilityParams; diff --git a/test/annotation_layer_builder_overrides.css b/test/annotation_layer_builder_overrides.css index c2d13407a..b1ca12395 100644 --- a/test/annotation_layer_builder_overrides.css +++ b/test/annotation_layer_builder_overrides.css @@ -35,3 +35,9 @@ .annotationLayer .popupWrapper { display: block; } + +.annotationLayer .popup h1, +.annotationLayer .popup p { + margin: 0; + padding: 0; +} diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 9e1af94a2..b26b40de6 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -131,6 +131,34 @@ describe('annotation', function() { dict = ref = null; }); + it('should set and get a valid creation date', function() { + const annotation = new Annotation({ dict, ref, }); + annotation.setCreationDate('D:20190422'); + + expect(annotation.creationDate).toEqual('D:20190422'); + }); + + it('should set and get an invalid creation date', function() { + const annotation = new Annotation({ dict, ref, }); + annotation.setCreationDate(undefined); + + expect(annotation.creationDate).toEqual(null); + }); + + it('should set and get a valid modification date', function() { + const annotation = new Annotation({ dict, ref, }); + annotation.setModificationDate('D:20190422'); + + expect(annotation.modificationDate).toEqual('D:20190422'); + }); + + it('should set and get an invalid modification date', function() { + const annotation = new Annotation({ dict, ref, }); + annotation.setModificationDate(undefined); + + expect(annotation.modificationDate).toEqual(null); + }); + it('should set and get flags', function() { const annotation = new Annotation({ dict, ref, }); annotation.setFlags(13); @@ -1400,6 +1428,59 @@ describe('annotation', function() { }); describe('PopupAnnotation', function() { + it('should inherit properties from its parent', function(done) { + const parentDict = new Dict(); + parentDict.set('Type', Name.get('Annot')); + parentDict.set('Subtype', Name.get('Text')); + parentDict.set('CreationDate', 'D:20190422'); + parentDict.set('M', 'D:20190423'); + parentDict.set('C', [0, 0, 1]); + + const popupDict = new Dict(); + popupDict.set('Type', Name.get('Annot')); + popupDict.set('Subtype', Name.get('Popup')); + popupDict.set('Parent', parentDict); + + const popupRef = new Ref(13, 0); + const xref = new XRefMock([ + { ref: popupRef, data: popupDict, } + ]); + + AnnotationFactory.create(xref, popupRef, pdfManagerMock, + idFactoryMock).then(({ data, viewable, }) => { + expect(data.annotationType).toEqual(AnnotationType.POPUP); + expect(data.creationDate).toEqual('D:20190422'); + expect(data.modificationDate).toEqual('D:20190423'); + expect(data.color).toEqual(new Uint8ClampedArray([0, 0, 255])); + done(); + }, done.fail); + }); + + it('should handle missing parent properties', function(done) { + const parentDict = new Dict(); + parentDict.set('Type', Name.get('Annot')); + parentDict.set('Subtype', Name.get('Text')); + + const popupDict = new Dict(); + popupDict.set('Type', Name.get('Annot')); + popupDict.set('Subtype', Name.get('Popup')); + popupDict.set('Parent', parentDict); + + const popupRef = new Ref(13, 0); + const xref = new XRefMock([ + { ref: popupRef, data: popupDict, } + ]); + + AnnotationFactory.create(xref, popupRef, pdfManagerMock, + idFactoryMock).then(({ data, viewable, }) => { + expect(data.annotationType).toEqual(AnnotationType.POPUP); + expect(data.creationDate).toEqual(null); + expect(data.modificationDate).toEqual(null); + expect(data.color).toEqual(null); + done(); + }, done.fail); + }); + it('should inherit the parent flags when the Popup is not viewable, ' + 'but the parent is (PR 7352)', function(done) { const parentDict = new Dict(); diff --git a/test/unit/display_utils_spec.js b/test/unit/display_utils_spec.js index 5dbbb4093..19a090559 100644 --- a/test/unit/display_utils_spec.js +++ b/test/unit/display_utils_spec.js @@ -15,7 +15,8 @@ /* eslint no-var: error */ import { - DOMCanvasFactory, DOMSVGFactory, getFilenameFromUrl, isValidFetchUrl + DOMCanvasFactory, DOMSVGFactory, getFilenameFromUrl, isValidFetchUrl, + PDFDateString } from '../../src/display/display_utils'; import isNodeJS from '../../src/shared/is_node'; @@ -220,4 +221,70 @@ describe('display_utils', function() { expect(isValidFetchUrl('https://www.example.com')).toEqual(true); }); }); + + describe('PDFDateString', function() { + describe('toDateObject', function() { + it('converts PDF date strings to JavaScript `Date` objects', function() { + const expectations = { + undefined: null, + null: null, + 42: null, + '2019': null, + 'D2019': null, + 'D:': null, + 'D:201': null, + 'D:2019': new Date(Date.UTC(2019, 0, 1, 0, 0, 0)), + 'D:20190': new Date(Date.UTC(2019, 0, 1, 0, 0, 0)), + 'D:201900': new Date(Date.UTC(2019, 0, 1, 0, 0, 0)), + 'D:201913': new Date(Date.UTC(2019, 0, 1, 0, 0, 0)), + 'D:201902': new Date(Date.UTC(2019, 1, 1, 0, 0, 0)), + 'D:2019020': new Date(Date.UTC(2019, 1, 1, 0, 0, 0)), + 'D:20190200': new Date(Date.UTC(2019, 1, 1, 0, 0, 0)), + 'D:20190232': new Date(Date.UTC(2019, 1, 1, 0, 0, 0)), + 'D:20190203': new Date(Date.UTC(2019, 1, 3, 0, 0, 0)), + // Invalid dates like the 31th of April are handled by JavaScript: + 'D:20190431': new Date(Date.UTC(2019, 4, 1, 0, 0, 0)), + 'D:201902030': new Date(Date.UTC(2019, 1, 3, 0, 0, 0)), + 'D:2019020300': new Date(Date.UTC(2019, 1, 3, 0, 0, 0)), + 'D:2019020324': new Date(Date.UTC(2019, 1, 3, 0, 0, 0)), + 'D:2019020304': new Date(Date.UTC(2019, 1, 3, 4, 0, 0)), + 'D:20190203040': new Date(Date.UTC(2019, 1, 3, 4, 0, 0)), + 'D:201902030400': new Date(Date.UTC(2019, 1, 3, 4, 0, 0)), + 'D:201902030460': new Date(Date.UTC(2019, 1, 3, 4, 0, 0)), + 'D:201902030405': new Date(Date.UTC(2019, 1, 3, 4, 5, 0)), + 'D:2019020304050': new Date(Date.UTC(2019, 1, 3, 4, 5, 0)), + 'D:20190203040500': new Date(Date.UTC(2019, 1, 3, 4, 5, 0)), + 'D:20190203040560': new Date(Date.UTC(2019, 1, 3, 4, 5, 0)), + 'D:20190203040506': new Date(Date.UTC(2019, 1, 3, 4, 5, 6)), + 'D:20190203040506F': new Date(Date.UTC(2019, 1, 3, 4, 5, 6)), + 'D:20190203040506Z': new Date(Date.UTC(2019, 1, 3, 4, 5, 6)), + 'D:20190203040506-': new Date(Date.UTC(2019, 1, 3, 4, 5, 6)), + 'D:20190203040506+': new Date(Date.UTC(2019, 1, 3, 4, 5, 6)), + 'D:20190203040506+\'': new Date(Date.UTC(2019, 1, 3, 4, 5, 6)), + 'D:20190203040506+0': new Date(Date.UTC(2019, 1, 3, 4, 5, 6)), + 'D:20190203040506+01': new Date(Date.UTC(2019, 1, 3, 3, 5, 6)), + 'D:20190203040506+00\'': new Date(Date.UTC(2019, 1, 3, 4, 5, 6)), + 'D:20190203040506+24\'': new Date(Date.UTC(2019, 1, 3, 4, 5, 6)), + 'D:20190203040506+01\'': new Date(Date.UTC(2019, 1, 3, 3, 5, 6)), + 'D:20190203040506+01\'0': new Date(Date.UTC(2019, 1, 3, 3, 5, 6)), + 'D:20190203040506+01\'00': new Date(Date.UTC(2019, 1, 3, 3, 5, 6)), + 'D:20190203040506+01\'60': new Date(Date.UTC(2019, 1, 3, 3, 5, 6)), + 'D:20190203040506+0102': new Date(Date.UTC(2019, 1, 3, 3, 3, 6)), + 'D:20190203040506+01\'02': new Date(Date.UTC(2019, 1, 3, 3, 3, 6)), + 'D:20190203040506+01\'02\'': new Date(Date.UTC(2019, 1, 3, 3, 3, 6)), + // Offset hour and minute that result in a day change: + 'D:20190203040506+05\'07': new Date(Date.UTC(2019, 1, 2, 22, 58, 6)), + }; + + for (const [input, expectation] of Object.entries(expectations)) { + const result = PDFDateString.toDateObject(input); + if (result) { + expect(result.getTime()).toEqual(expectation.getTime()); + } else { + expect(result).toEqual(expectation); + } + } + }); + }); + }); }); diff --git a/web/annotation_layer_builder.css b/web/annotation_layer_builder.css index 191360f7e..7384f4115 100644 --- a/web/annotation_layer_builder.css +++ b/web/annotation_layer_builder.css @@ -158,25 +158,33 @@ z-index: 200; max-width: 20em; background-color: #FFFF99; - box-shadow: 0px 2px 5px #333; + box-shadow: 0px 2px 5px #888; border-radius: 2px; - padding: 0.6em; + padding: 6px; margin-left: 5px; cursor: pointer; font: message-box; + font-size: 9px; word-wrap: break-word; } +.annotationLayer .popup > * { + font-size: 9px; +} + .annotationLayer .popup h1 { - font-size: 1em; - border-bottom: 1px solid #000000; - margin: 0; - padding-bottom: 0.2em; + display: inline-block; +} + +.annotationLayer .popup span { + display: inline-block; + margin-left: 5px; } .annotationLayer .popup p { - margin: 0; - padding-top: 0.2em; + border-top: 1px solid #333; + margin-top: 2px; + padding-top: 2px; } .annotationLayer .highlightAnnotation, diff --git a/web/pdf_document_properties.js b/web/pdf_document_properties.js index 168ac14f2..a73400b2c 100644 --- a/web/pdf_document_properties.js +++ b/web/pdf_document_properties.js @@ -13,10 +13,10 @@ * limitations under the License. */ +import { createPromiseCapability, PDFDateString } from 'pdfjs-lib'; import { getPageSizeInches, getPDFFileNameFromURL, isPortraitOrientation, NullL10n } from './ui_utils'; -import { createPromiseCapability } from 'pdfjs-lib'; const DEFAULT_FIELD_CONTENT = '-'; @@ -363,50 +363,14 @@ class PDFDocumentProperties { * @private */ _parseDate(inputDate) { - if (!inputDate) { - return; + const dateObject = PDFDateString.toDateObject(inputDate); + if (dateObject) { + const dateString = dateObject.toLocaleDateString(); + const timeString = dateObject.toLocaleTimeString(); + return this.l10n.get('document_properties_date_string', + { date: dateString, time: timeString, }, + '{{date}}, {{time}}'); } - // This is implemented according to the PDF specification, but note that - // Adobe Reader doesn't handle changing the date to universal time - // and doesn't use the user's time zone (they're effectively ignoring - // the HH' and mm' parts of the date string). - let dateToParse = inputDate; - - // Remove the D: prefix if it is available. - if (dateToParse.substring(0, 2) === 'D:') { - dateToParse = dateToParse.substring(2); - } - - // Get all elements from the PDF date string. - // JavaScript's `Date` object expects the month to be between - // 0 and 11 instead of 1 and 12, so we're correcting for this. - let year = parseInt(dateToParse.substring(0, 4), 10); - let month = parseInt(dateToParse.substring(4, 6), 10) - 1; - let day = parseInt(dateToParse.substring(6, 8), 10); - let hours = parseInt(dateToParse.substring(8, 10), 10); - let minutes = parseInt(dateToParse.substring(10, 12), 10); - let seconds = parseInt(dateToParse.substring(12, 14), 10); - let utRel = dateToParse.substring(14, 15); - let offsetHours = parseInt(dateToParse.substring(15, 17), 10); - let offsetMinutes = parseInt(dateToParse.substring(18, 20), 10); - - // As per spec, utRel = 'Z' means equal to universal time. - // The other cases ('-' and '+') have to be handled here. - if (utRel === '-') { - hours += offsetHours; - minutes += offsetMinutes; - } else if (utRel === '+') { - hours -= offsetHours; - minutes -= offsetMinutes; - } - - // Return the new date format from the user's locale. - let date = new Date(Date.UTC(year, month, day, hours, minutes, seconds)); - let dateString = date.toLocaleDateString(); - let timeString = date.toLocaleTimeString(); - return this.l10n.get('document_properties_date_string', - { date: dateString, time: timeString, }, - '{{date}}, {{time}}'); } /**