모두의 PDF 에서도 일부 기능을 사용중이긴 합니다만.. 최근에 pdf.js 를 가지고 이런저런 일들을 하는 경우가 많습니다.
모질라의 오픈소스이고 Firefox 에서 기본 PDF Viewer를 담당하는 놈이다보니 이래저래 사용하시는 분들이 꽤 되는 걸로 알고 있습니다.
코드를 수정(이라 쓰고 마개조라 읽는다)해서 쓰고 있는 와중에.. 제가 쓰는 목적에선 문제가 있어서 수정한 부분을 공유합니다.
사실;; pdf.js 사용하는 다른 사용자들에게도 해당하지 않을까 싶어서.. pdf.js 쪽에다가 Full Request를 신나게 때리고보니;; 잠시 잊고 있었어요 ㅎㅎ 공동개발하는 대부분의 프로젝트들은 이런저런 컨벤션이니 Lint 오류가 안나게 잘 맞춰서 ;; 때려야 한다는걸.. ;;; 다 맞춰서 다시 풀리퀘를 때릴까 싶다가도.. 그냥 귀찮아서.. 혹시나 같은 문제를 고민하시는 분들을 위해 블로그에 간단히 글로써 올려봅니다.
https://mozilla.github.io/pdf.js/web/viewer.html
요 주소에 들어가 보시면 .. pdf.js 의 데모 페이지를 열 수 있는 페이지가 열립니다.

기본적으로 이런 형태의 pdf.js 가 열리게 되는데요..

테스트에 사용될 이미지는 400×400 픽셀의 Opacity 값이 존재하는 반투명한 이미지입니다. pdf.js 에서는 기본적으로 FreeText, Line Draw 를 통한 텍스트입력, 선그리기 등을 지원하고 이미지를 Stamp Annotation 으로 입력 가능한 기능이 존재합니다.
위의 400×400 의 png 이미지를 추가 해보겠습니다.

이렇게 반투명한 붉은색 박스이미지가 PDF에 올라간 것을 볼 수 있습니다.
이걸 저장을 한뒤에 다시 열어 보겠습니다.

뭔가 흐릿?? 해졌죠??
지금부터 이런 현상이 일어나는 원인에 대해서 간단히 설명 드리겠습니다.
PDF 에는 일반적으로 JPEG, PNG, TIFF 3가지 종류의 이미지 데이터를 입력이 가능합니다만, JPEG는 DCTFilter를 사용해서 JPEG 이미지 데이터를 그대로 포함하지만 PNG의 경우는 압축포맷이 아니라 FlateDecode라는 zlib 로 따로 압축을 해야하고 이미지 데이터 자체도 PNG 파일의 바이너리 스트림을 그대로 넣는게 아닌 PDF의 이미지 데이터 형태에 맞춰서 저장을 해야합니다. TIFF의 경우에는 lzw 로 따로 압축을 하는 관계로 다른 필터를 또 먹여야 하구요.
게다가 가장큰 특징은 PNG 의 경우 이미지 자체에 RGBA로 Aplha Channel 그러니까 투명도를 포함 하여 저장이 가능함에도 PDF에서는 투명값 정보에 대해서는 Smask 라 하는 별도의 Stream DICT로 따로 저장을 하게 됩니다.
그러니까 PNG같은 투명값을 포함한 이미지파일을 저장 하더라도 .. 이미지 데이터에는 RGB 값만을 형태에 맞춰서 구성후 zlib 라이브러리로 압축하여 DICT의 stream 데이터를 구성해야 하고 ..
투명값에 대해서는 따로 DICT를 구성해서 작성을 해야 한다는 겁니다.
이제 .. pdf.js 에서 기존에 어떤식으로 처리를 해뒀는지를 봅시다.
관련 파일은 pdfjs/src/core/annotation.js 파일입니다.
이 파일의 내용중 StampAnnotation 클래스를 찾아가 보시면 createImage 라는 메소드가 작성되어 있습니다.
static async createImage(bitmap, xref) {
// TODO: when printing, we could have a specific internal colorspace
// (e.g. something like DeviceRGBA) in order avoid any conversion (i.e. no
// jpeg, no rgba to rgb conversion, etc...)
const { width, height } = bitmap;
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d", { alpha: true });
// Draw the image and get the data in order to extract the transparency.
ctx.drawImage(bitmap, 0, 0);
const data = ctx.getImageData(0, 0, width, height).data;
const buf32 = new Uint32Array(data.buffer);
const hasAlpha = buf32.some(
FeatureTest.isLittleEndian
? x => x >>> 24 !== 0xff
: x => (x & 0xff) !== 0xff
);
if (hasAlpha) {
// Redraw the image on a white background in order to remove the thin gray
// line which can appear when exporting to jpeg.
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
ctx.drawImage(bitmap, 0, 0);
}
const jpegBufferPromise = canvas
.convertToBlob({ type: "image/jpeg", quality: 1 })
.then(blob => blob.arrayBuffer());
const xobjectName = Name.get("XObject");
const imageName = Name.get("Image");
const image = new Dict(xref);
image.set("Type", xobjectName);
image.set("Subtype", imageName);
image.set("BitsPerComponent", 8);
image.set("ColorSpace", Name.get("DeviceRGB"));
image.set("Filter", Name.get("DCTDecode"));
image.set("BBox", [0, 0, width, height]);
image.set("Width", width);
image.set("Height", height);
let smaskStream = null;
if (hasAlpha) {
const alphaBuffer = new Uint8Array(buf32.length);
if (FeatureTest.isLittleEndian) {
for (let i = 0, ii = buf32.length; i < ii; i++) {
alphaBuffer[i] = buf32[i] >>> 24;
}
} else {
for (let i = 0, ii = buf32.length; i < ii; i++) {
alphaBuffer[i] = buf32[i] & 0xff;
}
}
const smask = new Dict(xref);
smask.set("Type", xobjectName);
smask.set("Subtype", imageName);
smask.set("BitsPerComponent", 8);
smask.set("ColorSpace", Name.get("DeviceGray"));
smask.set("Width", width);
smask.set("Height", height);
smaskStream = new Stream(alphaBuffer, 0, 0, smask);
}
const imageStream = new Stream(await jpegBufferPromise, 0, 0, image);
return {
imageStream,
smaskStream,
width,
height,
};
}이렇게 createImage 메소드가 작성되어 있습니다만..
코드의 흐름을 보면 .. OffScreenCanvas 에다 파라미터로 받은 bitmap (ImageBitmap 일겁니다.)을 그린 뒤에 캔버스에 그려진 이미지의 데이터를 가져온 후 이 데이터를 가지고 PDF에 기록될 DICT 구문을 생성하는 코드입니다.
const hasAlpha = buf32.some(
FeatureTest.isLittleEndian
? x => x >>> 24 !== 0xff
: x => (x & 0xff) !== 0xff
);getImageData 로 얻은 캔버스상의 이미지 데이터상에 투명값이 존재하는 데이터가 있는지를 확인헤서 hasAlpha 에 값을 담고 있는데요( true or false )
if (hasAlpha) {
// Redraw the image on a white background in order to remove the thin gray
// line which can appear when exporting to jpeg.
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
ctx.drawImage(bitmap, 0, 0);
}만약 투명값이 존재하는 경우에는 .. 배경을 흰색으로 채운뒤에 이미지를 다시 그립니…다?? (응? 왜?)
그 이유는 이후 코드를 보게 되면 알 수 있는데요 ..
const jpegBufferPromise = canvas
.convertToBlob({ type: "image/jpeg", quality: 1 })
.then(blob => blob.arrayBuffer());투명 값이 있던 없던 .. 무조건 jpeg 로 스트림을 생성하도록 되어 있습니다. 다들 잘 아시겠지만 JPEG형식은 가장 범용적으로 쓰이는 형태의 이미지 파일 포맷이지만 투명값을 처리하지 못합니다.
if (hasAlpha) {
const alphaBuffer = new Uint8Array(buf32.length);
if (FeatureTest.isLittleEndian) {
for (let i = 0, ii = buf32.length; i < ii; i++) {
alphaBuffer[i] = buf32[i] >>> 24;
}
} else {
for (let i = 0, ii = buf32.length; i < ii; i++) {
alphaBuffer[i] = buf32[i] & 0xff;
}
}
const smask = new Dict(xref);
smask.set("Type", xobjectName);
smask.set("Subtype", imageName);
smask.set("BitsPerComponent", 8);
smask.set("ColorSpace", Name.get("DeviceGray"));
smask.set("Width", width);
smask.set("Height", height);
smaskStream = new Stream(alphaBuffer, 0, 0, smask);
}물론 투명도가 존재하는 정보는 따로 추출해서 Smask DICT를 구성하여 저장은 하고 있습니다만..
여기서 모든 문제가 발생하는 것이지요..
투명이라는것이 완전한 투명도 있지만 제가 예제로 쓴 빨간 박스이미지 같이 반투명한 경우도 있습니다. 이러한 반투명 이미지를 배경이 흰색인 캔버스 위에다가 반투명 상태 그대로 그려버리면 .. 흰색 위에 반투명한 빨간색으로 .. 약간 핑크;; 느낌의 색으로 변하게 되지요..
그렇게 저장된 이미지를 .. 다시 투명 정보만을 추가해서 투명도를 추가한다 한들 ..
결국 사용자가 입력한 이미지와는 다른 이미지가 되는거죠..
그래서 관련 코드를 수정 했습니다.
static encodePNGFilter(data, columns) {
if (data.length % columns !== 0) {
return null;
}
let encoded_data = [];
let colorCount = 3;
let encoded_count = 0;
for (let i = 0; i < data.length; i++) {
if ((i % 4) === 0) {
for (let j = i; j < i + colorCount; j++) {
if ((encoded_count % columns) === 0) {
encoded_data.push(0);
}
encoded_data.push(data[j]);
encoded_count++;
}
}
}
return new Uint8Array(encoded_data);
}
static async createImage(bitmap, xref) {
const { width, height } = bitmap;
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d", { alpha: true });
// Draw the image and get the data in order to extract the transparency.
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
const buf32 = new Uint32Array(data.buffer);
const hasAlpha = buf32.some(
FeatureTest.isLittleEndian
? x => x >>> 24 !== 0xff
: x => (x & 0xff) !== 0xff
);
if (hasAlpha) {
const xobjectName = Name.get("XObject");
const imageName = Name.get("Image");
const image = new Dict(xref);
image.set("Type", xobjectName);
image.set("Subtype", imageName);
image.set("BitsPerComponent", 8);
image.set("ColorSpace", Name.get("DeviceRGB"));
image.set("Filter", Name.get("FlateDecode"));
image.set("BBox", [0, 0, width, height]);
image.set("Width", width);
image.set("Height", height);
const decodeParams = new Dict(xref);
decodeParams.set("Predictor", 12);
decodeParams.set("Columns", width);
decodeParams.set("Colors", 1);
image.set("DecodeParms", decodeParams);
let smaskStream = null;
// create alphaChannel
const alphaChannel = [];
for (let i = 3; i < data.length; i += 4) {
alphaChannel.push(data[i]&0xff);
}
const alphaBuffer = new Uint8Array(alphaChannel);
const smask = new Dict(xref);
smask.set("Type", xobjectName);
smask.set("Subtype", imageName);
smask.set("BitsPerComponent", 8);
smask.set("ColorSpace", Name.get("DeviceGray"));
smask.set("Filter", Name.get("FlateDecode"));
smask.set("Width", width);
smask.set("Height", height);
smaskStream = new Stream(alphaBuffer.buffer, 0, 0, smask);
// end of alphaChannel
const buffer = new Uint8Array(data.buffer);
const encodePng = this.encodePNGFilter(buffer, width);
const imageStream = new Stream(encodePng.buffer, 0, 0, image);
return {
imageStream,
smaskStream,
width,
height,
};
} else {
let smaskStream = null;
const jpegBufferPromise = canvas
.convertToBlob({ type: 'image/jpeg', quality : 1})
.then(blob => blob.arrayBuffer());
const xobjectName = Name.get("XObject");
const imageName = Name.get("Image");
const image = new Dict(xref);
image.set("Type", xobjectName);
image.set("Subtype", imageName);
image.set("BitsPerComponent", 8);
image.set("ColorSpace", Name.get("DeviceRGB"));
image.set("Filter", Name.get("DCTDecode"));
image.set("BBox", [0, 0, width, height]);
image.set("Width", width);
image.set("Height", height);
const imageStream = new Stream(await jpegBufferPromise, 0, 0, image);
return {
imageStream,
smaskStream,
width,
height,
};
}
}hasAlpha 값을 기준하여 .. 투명값이 존재하면 투명한 이미지 그대로 DICT를 생성하여 저장할 수 있도록 수정하였습니다. 사실 PNG이미지의 저장시에는 DecodeParms에 전달하는 Columns와 Predictor 값, Colors 값에 따라서 stream 에 저장된 데이터의 형태를 구분하여 읽게 되는데요. 보통 5가지 정도의 다른 형태로 저장을 할 수 있으나 .. 전 그냥 추가적인 PNG 예측모델?(GPT가 이렇게 번역하는데 의미는 잘 모르겠습니다.) 없이 그냥 데이터를 저장하는 형태로만 고정하여 작성하였습니다.
기존 createImage 메소드를 지우시고 위에 두 코드를 넣으시면 .. 관련 문제를 해결하실 수 있습니다.
pdf.js 쪽에 풀 리퀘스트를 올렸을때 .. 어차피 smask 를 통해 투명 처리를 따로 하는데 굳이 이부분의 수정이 필요하냐? 라는 코멘트를 적은 분이 계셨는데요.. 완전 투명의 경우에는 몰라도 .. 반투명한 경우에는 아무리 smask 를 통해 투명도 처리를 따로 한다 한들 .. 흰색 배경위에 반투명 이미지를 한번 찍어서 저장한 JPEG 이미지 데이터를 읽어서 투명값을 따로 설정 해서 문제가 안될 상황은 반투명이 아닌 완전 투명한 영역에 대해서는 문제 될 것이 없겠지요.. 완전 투명한 영역이면 흰색으로 체워져 있을것이고 그렇다 해도 그 영역을 완전 투명으로 찍어버릴꺼니 문제가 안되겠지만.. 반투명의 경우에는 어쨋든 다른 이미지가 되서 저장되는 문제가 됩니다.
이상입니다.
댓글을 달려면 로그인해야 합니다.