diff --git a/examples/embedicc.js b/examples/embedicc.js new file mode 100644 index 00000000..192c8eba --- /dev/null +++ b/examples/embedicc.js @@ -0,0 +1,20 @@ +const PDFDocument=require('..'); +const fs=require('fs'); +// Create a new PDFDocument +let doc = new PDFDocument(); +doc.pipe(fs.createWriteStream('embedicc.pdf')); +// Set some meta data +doc.info['Title'] = 'Test Document'; +doc.info['Author'] = 'xiaohui'; +doc + .text('noICC', 40, 50) + .image('images/landscape.jpg', 40, 70, { + width: 200, + height: 267 + }) + .text('embedICC', 280, 50) + .image('images/landscape+icc.jpg', 280, 70, { + width: 200, + height: 267 +}); +doc.end(); diff --git a/examples/embedicc.pdf b/examples/embedicc.pdf new file mode 100644 index 00000000..de6a0d7b Binary files /dev/null and b/examples/embedicc.pdf differ diff --git a/examples/images/landscape+icc.jpg b/examples/images/landscape+icc.jpg new file mode 100644 index 00000000..0554bee8 Binary files /dev/null and b/examples/images/landscape+icc.jpg differ diff --git a/examples/images/landscape.jpg b/examples/images/landscape.jpg new file mode 100644 index 00000000..67aa91d7 Binary files /dev/null and b/examples/images/landscape.jpg differ diff --git a/lib/image/icc_profile.js b/lib/image/icc_profile.js new file mode 100644 index 00000000..12b58637 --- /dev/null +++ b/lib/image/icc_profile.js @@ -0,0 +1,187 @@ +import zlib from 'zlib'; +class ICCProfile { + static extractFromJPEG(jpeg) { + let pos = 2; + const buffers = []; + while (pos < jpeg.length) { + const marker = jpeg.readUInt16BE(pos); + const length = jpeg.readUInt16BE(pos + 2); + if (marker === 0xFFE2) { + const signature = jpeg.toString('ascii', pos + 4, pos + 18); + if (signature.startsWith('ICC_PROFILE')) { + const data = jpeg.slice(pos + 18, pos + length + 2); + buffers.push(data); + } + } + pos += length + 2; + } + if (buffers.length == 0) return; + return Buffer.concat(buffers); + } + + static extractFromPNG(png) { + const pos = png.indexOf('iCCP'); + if (pos === -1) { + console.log('No iCCP chunk found'); + return; + } + const length = png.readUInt32BE(pos - 4); + const data = png.slice(pos + 8, pos + length + 8); + const nullPos = data.indexOf('\x00'); + const buffer = zlib.inflateSync(data.slice(nullPos + 2), { windowBits: 15 }); + return buffer; + } + + constructor(buffer) { + this.buffer = buffer; + this.data = this._parse(); + } + + _parse() { + const versionMap = { + 0x02000000: '2.0', + 0x02100000: '2.1', + 0x02400000: '2.4', + 0x04000000: '4.0', + 0x04200000: '4.2', + 0x04300000: '4.3' + }; + const intentMap = { + 0: 'Perceptual', + 1: 'Relative', + 2: 'Saturation', + 3: 'Absolute' + }; + const valueMap = { + // Device + scnr: 'Scanner', + mntr: 'Monitor', + prtr: 'Printer', + link: 'Link', + abst: 'Abstract', + spac: 'Space', + nmcl: 'Named color', + // Platform + appl: 'Apple', + adbe: 'Adobe', + msft: 'Microsoft', + sunw: 'Sun Microsystems', + sgi: 'Silicon Graphics', + tgnt: 'Taligent' + }; + const tagMap = { + desc: 'description', + cprt: 'copyright', + dmdd: 'deviceModelDescription', + vued: 'viewingConditionsDescription' + }; + const getContentAtOffsetAsString = (buffer, offset) => { + const value = buffer.slice(offset, offset + 4).toString().trim(); + return (value.toLowerCase() in valueMap) ? valueMap[value.toLowerCase()] : value; + }; + const hasContentAtOffset = (buffer, offset) => buffer.readUInt32BE(offset) !== 0; + const readStringUTF16BE = (buffer, start, end) => { + const data = buffer.slice(start, end); + let value = ''; + for (let i = 0; i < data.length; i += 2) { + value += String.fromCharCode((data[i] * 256) + data[i + 1]); + } + return value; + }; + const invalid = (reason) => new Error(`Invalid ICC profile: ${reason}`); + const parse = (buffer) => { + // Verify expected length + const size = buffer.readUInt32BE(0); + if (size !== buffer.length) { + throw invalid('length mismatch'); + } + // Verify 'acsp' signature + const signature = buffer.slice(36, 40).toString(); + if (signature !== 'acsp') { + throw invalid('missing signature'); + } + // Integer attributes + const profile = { + version: versionMap[buffer.readUInt32BE(8)], + intent: intentMap[buffer.readUInt32BE(64)] + }; + // Four-byte string attributes + [ + [4, 'cmm'], + [12, 'deviceClass'], + [16, 'colorSpace'], + [20, 'connectionSpace'], + [40, 'platform'], + [48, 'manufacturer'], + [52, 'model'], + [80, 'creator'] + ].forEach(attr => { + if (hasContentAtOffset(buffer, attr[0])) { + profile[attr[1]] = getContentAtOffsetAsString(buffer, attr[0]); + } + }); + // Tags + const tagCount = buffer.readUInt32BE(128); + let tagHeaderOffset = 132; + for (let i = 0; i < tagCount; i++) { + const tagSignature = getContentAtOffsetAsString(buffer, tagHeaderOffset); + if (tagSignature in tagMap) { + const tagOffset = buffer.readUInt32BE(tagHeaderOffset + 4); + const tagSize = buffer.readUInt32BE(tagHeaderOffset + 8); + if (tagOffset > buffer.length) { + throw invalid('tag offset out of bounds'); + } + const tagType = getContentAtOffsetAsString(buffer, tagOffset); + // desc + if (tagType === 'desc') { + const tagValueSize = buffer.readUInt32BE(tagOffset + 8); + if (tagValueSize > tagSize) { + throw invalid(`description tag value size out of bounds for ${tagSignature}`); + } + profile[tagMap[tagSignature]] = buffer.slice(tagOffset + 12, tagOffset + tagValueSize + 11).toString(); + } + // text + if (tagType === 'text') { + profile[tagMap[tagSignature]] = buffer.slice(tagOffset + 8, tagOffset + tagSize - 7).toString(); + } + if (tagType === 'mluc' && tagSignature in tagMap) { + // 4 bytes signature, 4 bytes reserved (must be 0), 4 bytes number of names, 4 bytes name record size (must be 12) + const numberOfNames = buffer.readUInt32BE(tagOffset + 8); + const nameRecordSize = buffer.readUInt32BE(tagOffset + 12); + if (nameRecordSize !== 12) { + throw invalid(`mluc name record size must be 12 for tag ${tagSignature}`); + } + if (numberOfNames > 0) { + // Entry: 2 bytes language code, 2 bytes country code, 4 bytes length, 4 bytes offset from start of tag + // const languageCode = buffer.slice(tagOffset + 16, tagOffset + 18).toString(); + // const countryCode = buffer.slice(tagOffset + 18, tagOffset + 20).toString(); + const nameLength = buffer.readUInt32BE(tagOffset + 20); + const nameOffset = buffer.readUInt32BE(tagOffset + 24); + const nameStart = tagOffset + nameOffset; + const nameStop = nameStart + nameLength; + profile[tagMap[tagSignature]] = readStringUTF16BE(buffer, nameStart, nameStop); + } + } + } + tagHeaderOffset = tagHeaderOffset + 12; + } + return profile; + }; + return parse(this.buffer); + } + + embed(document, alternate, channels) { + if(!this.data)return alternate; + const profile=document.ref({ + Alternate: alternate, + N: channels, + Length: this.buffer.length + }) + profile.write(this.buffer); + profile.end(); + const colorSpace = document.ref([`ICCBased ${profile}`]); + colorSpace.end(); + return colorSpace; + } +} +export default ICCProfile; \ No newline at end of file diff --git a/lib/image/jpeg.js b/lib/image/jpeg.js index 4d556c85..2ca8347b 100644 --- a/lib/image/jpeg.js +++ b/lib/image/jpeg.js @@ -1,4 +1,5 @@ import exif from 'jpeg-exif'; +import ICCProfile from './icc_profile' const MARKERS = [ 0xffc0, @@ -58,24 +59,31 @@ class JPEG { this.width = this.data.readUInt16BE(pos); pos += 2; - const channels = this.data[pos++]; - this.colorSpace = COLOR_SPACE_MAP[channels]; + this.channels = this.data[pos++]; + this.colorSpace = COLOR_SPACE_MAP[this.channels] this.obj = null; } - embed(document) { + embed(document, {embedICCProfile=true}={}) { if (this.obj) { return; } - + let colorSpace=this.colorSpace; + if(embedICCProfile){ + let iccProfile = ICCProfile.extractFromJPEG(this.data); + if(iccProfile){ + iccProfile = new ICCProfile(iccProfile); + colorSpace = iccProfile.embed(document, colorSpace, this.channels); + } + } this.obj = document.ref({ Type: 'XObject', Subtype: 'Image', BitsPerComponent: this.bits, Width: this.width, Height: this.height, - ColorSpace: this.colorSpace, + ColorSpace: colorSpace, Filter: 'DCTDecode' }); @@ -87,10 +95,9 @@ class JPEG { } this.obj.end(this.data); - // free memory return (this.data = null); } } -export default JPEG; +export default JPEG; \ No newline at end of file diff --git a/lib/image/png.js b/lib/image/png.js index 15acf13b..89a8120a 100644 --- a/lib/image/png.js +++ b/lib/image/png.js @@ -1,9 +1,10 @@ import zlib from 'zlib'; import PNG from 'png-js'; - +import ICCProfile from './icc_profile' class PNGImage { constructor(data, label) { this.label = label; + this.data = data; this.image = new PNG(data); this.width = this.image.width; this.height = this.image.height; @@ -11,7 +12,7 @@ class PNGImage { this.obj = null; } - embed(document) { + embed(document, {embedICCProfile=true}={}) { let dataDecoded = false; this.document = document; @@ -44,7 +45,15 @@ class PNGImage { } if (this.image.palette.length === 0) { - this.obj.data['ColorSpace'] = this.image.colorSpace; + let colorSpace = this.image.colorSpace; + if(embedICCProfile){ + let iccProfile = ICCProfile.extractFromPNG(this.data); + if(iccProfile){ + iccProfile = new ICCProfile(iccProfile); + colorSpace = iccProfile.embed(document, colorSpace, this.image.colors); + } + } + this.obj.data['ColorSpace']=colorSpace; } else { // embed the color palette in the PDF as an object stream const palette = this.document.ref(); diff --git a/lib/mixins/images.js b/lib/mixins/images.js index f2ead673..93e7f6ba 100644 --- a/lib/mixins/images.js +++ b/lib/mixins/images.js @@ -34,7 +34,7 @@ export default { } if (!image.obj) { - image.embed(this); + image.embed(this, options); } if (this.page.xobjects[image.label] == null) { diff --git a/tests/images/landscape+icc.jpg b/tests/images/landscape+icc.jpg new file mode 100644 index 00000000..0554bee8 Binary files /dev/null and b/tests/images/landscape+icc.jpg differ diff --git a/tests/images/landscape+icc.png b/tests/images/landscape+icc.png new file mode 100644 index 00000000..ff22de30 Binary files /dev/null and b/tests/images/landscape+icc.png differ diff --git a/tests/images/landscape+micc.jpg b/tests/images/landscape+micc.jpg new file mode 100644 index 00000000..81b8cc00 Binary files /dev/null and b/tests/images/landscape+micc.jpg differ diff --git a/tests/unit/icc_profile.spec.js b/tests/unit/icc_profile.spec.js new file mode 100644 index 00000000..d1e98346 --- /dev/null +++ b/tests/unit/icc_profile.spec.js @@ -0,0 +1,114 @@ +import PDFDocument from '../../lib/document'; +import {logData} from '../unit/helpers'; +import ICCProfile from '../../lib/image/icc_profile' +import fs from 'fs' +describe('ICCProfile', () => { + test('extractFromJPEG', () => { + let filePath = 'tests/images/landscape+icc.jpg' + let buf=fs.readFileSync(filePath); + let iccProfile = ICCProfile.extractFromJPEG(buf); + iccProfile=new ICCProfile(iccProfile); + expect(iccProfile.data.description).toBe('Display P3'); + expect(iccProfile.buffer.length).toBe(536); + }) + test('extractFromJPEG', () => { + let filePath = 'tests/images/landscape+micc.jpg' + let buf=fs.readFileSync(filePath); + let iccProfile = ICCProfile.extractFromJPEG(buf); + iccProfile=new ICCProfile(iccProfile); + expect(iccProfile.data.description).toBe('Modified Display P3'); + expect(iccProfile.buffer.length).toBe(76008); + }) + test('extractFromPNG', () => { + let filePath = 'tests/images/landscape+icc.png' + let buf=fs.readFileSync(filePath); + let iccProfile = ICCProfile.extractFromPNG(buf); + iccProfile=new ICCProfile(iccProfile); + expect(iccProfile.data.description).toBe('Display P3'); + expect(iccProfile.buffer.length).toBe(536); + }) + test('embedICCProfile-PNG', () => { + let doc = new PDFDocument(); + const data = logData(doc); + doc.image('tests/images/landscape+icc.png', 40, 70, { + width: 200, + height: 267 + }); + doc.end(); + expect(data).toContainChunk([ + '10 0 obj', + '<<\n/Alternate /DeviceRGB\n/N 3\n/Length 349\n/Filter /FlateDecode\n>>' + ]); + expect(data).toContainChunk([ + '11 0 obj', + '[/ICCBased 10 0 R]' + ]); + expect(data).toContainChunk([ + '8 0 obj', + '<<\n' + + '/Type /XObject\n' + + '/Subtype /Image\n' + + '/BitsPerComponent 8\n' + + '/Width 804\n' + + '/Height 1071\n' + + '/Filter /FlateDecode\n' + + '/DecodeParms 9 0 R\n' + + '/ColorSpace 11 0 R\n' + + '/Length 2258706\n' + + '>>' + ]); + }); + test('embedICCProfile-JPEG', () => { + let doc = new PDFDocument(); + const data = logData(doc); + doc.image('tests/images/landscape+icc.jpg', 40, 70, { + width: 200, + height: 267 + }); + doc.end(); + expect(data).toContainChunk([ + '8 0 obj', + '<<\n/Alternate /DeviceRGB\n/N 3\n/Length 349\n/Filter /FlateDecode\n>>' + ]); + expect(data).toContainChunk([ + '9 0 obj', + '[/ICCBased 8 0 R]' + ]); + expect(data).toContainChunk([ + '10 0 obj', + '<<\n' + + '/Type /XObject\n' + + '/Subtype /Image\n' + + '/BitsPerComponent 8\n' + + '/Width 804\n' + + '/Height 1071\n' + + '/ColorSpace 9 0 R\n' + + '/Filter /DCTDecode\n' + + '/Length 553372\n' + + '>>', + ]); + }); + test('notEmbedICCProfile', () => { + let doc = new PDFDocument(); + const data = logData(doc); + doc.image('tests/images/landscape+micc.jpg', 40, 70, { + width: 200, + height: 267, + embedICCProfile: false + }); + doc.end(); + expect(data).toContainChunk([ + '8 0 obj', + '<<\n' + + '/Type /XObject\n' + + '/Subtype /Image\n' + + '/BitsPerComponent 8\n' + + '/Width 804\n' + + '/Height 1071\n' + + '/ColorSpace /DeviceRGB\n' + + '/Filter /DCTDecode\n' + + '/Length 626530\n' + + '>>', + ]); + }); +}) \ No newline at end of file diff --git a/tests/unit/png.spec.js b/tests/unit/png.spec.js index d5171bee..f28aa523 100644 --- a/tests/unit/png.spec.js +++ b/tests/unit/png.spec.js @@ -26,7 +26,7 @@ describe('PNGImage', () => { }; const finalizeFn = img.finalize; jest.spyOn(img, 'finalize').mockImplementation(() => finalizeFn.call(img)); - img.embed(document); + img.embed(document, {embedICCProfile: false}); return img; };