모두의 프린터는 어떠한 경우에도 본인인증, 회원가입, 카드결제를 요구하지 않습니다.
다운로드를 유도하는 애드센스 광고를 피로곰이 배포하는 프로그램들의 다운로드 링크로 착각하지 않도록 주의하시기 바랍니다. 피로곰이 배포하는 모든 프로그램은 본인인증, 회원가입, 이용료 결제 없이 무료로 사용가능합니다.
많이 찾는 글들...
  1. Ghostscript/GhostPCL 설치 안내
  2. 파일 다운로드가 차단되는 경우
  3. 내 컴퓨터의 32비트,64비트 여부 아는법
  4. 'Windows의 PC 보호' 문제
  5. 모두의 프린터 실행후 환경설정창 뜨지 않고 무반응
  6. Ghostscript PDF변환 불가. Can't load Ghostscript DLL
  7. 대법원 인터넷 등기소, 전자소송, 경매,전자공탁등 대법원계열 사이트관련
  8. 모두의 프린터 사용후 네트워크 장애가 발생하는경우. (대법원 인터넷등기소 등)
  9. 출력시 모두의 프린터가 강제종료 되는 경우.'지원하지 않는 PDF또는 가상 프린터입니다.'
  10. 오픽(OPic), 연결상태 확인 불가 프린터, 등록되지 않은 프린터(MarkAny e-PageSAFER)
  11. YBM 토익성적표 관련(정상적인 프린터로 출력을 진행하시기 바랍니다)
  12. 리포트뷰어(ReportViewer) 관련(특수목적프린터, 문서변환 프로그램을 제거해주세요)
  13. 인터넷증명발급센터 서트피아(Certpia) 관련 안내
  14. 인강사이트 관련 - 출력에 매우 오래 걸림, PDF파일 버벅거림, PDF여는데 오래걸림 등등
  15. '잘못된 프린터 데이터를 수신하였습니다.' 문제
  16. MS서피스, 삼성 갤럭시북 등 ARM기반 랩탑, '잘못된 프린터 데이터를 수신하였습니다.' 문제

pdf.js Opacity를 포함한 Stamp Annotation 패치

모두의 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 라는 메소드가 작성되어 있습니다.

JavaScript
 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 구문을 생성하는 코드입니다.

JavaScript
    const hasAlpha = buf32.some(
      FeatureTest.isLittleEndian
        ? x => x >>> 24 !== 0xff
        : x => (x & 0xff) !== 0xff
    );

getImageData 로 얻은 캔버스상의 이미지 데이터상에 투명값이 존재하는 데이터가 있는지를 확인헤서 hasAlpha 에 값을 담고 있는데요( true or false )

JavaScript
    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);
    }

만약 투명값이 존재하는 경우에는 .. 배경을 흰색으로 채운뒤에 이미지를 다시 그립니…다?? (응? 왜?)

그 이유는 이후 코드를 보게 되면 알 수 있는데요 ..

JavaScript
    const jpegBufferPromise = canvas
      .convertToBlob({ type: "image/jpeg", quality: 1 })
      .then(blob => blob.arrayBuffer());

투명 값이 있던 없던 .. 무조건 jpeg 로 스트림을 생성하도록 되어 있습니다. 다들 잘 아시겠지만 JPEG형식은 가장 범용적으로 쓰이는 형태의 이미지 파일 포맷이지만 투명값을 처리하지 못합니다.

JavaScript
    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를 구성하여 저장은 하고 있습니다만..

여기서 모든 문제가 발생하는 것이지요..

투명이라는것이 완전한 투명도 있지만 제가 예제로 쓴 빨간 박스이미지 같이 반투명한 경우도 있습니다. 이러한 반투명 이미지를 배경이 흰색인 캔버스 위에다가 반투명 상태 그대로 그려버리면 .. 흰색 위에 반투명한 빨간색으로 .. 약간 핑크;; 느낌의 색으로 변하게 되지요..

그렇게 저장된 이미지를 .. 다시 투명 정보만을 추가해서 투명도를 추가한다 한들 ..

결국 사용자가 입력한 이미지와는 다른 이미지가 되는거죠..

그래서 관련 코드를 수정 했습니다.

JavaScript
  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 이미지 데이터를 읽어서 투명값을 따로 설정 해서 문제가 안될 상황은 반투명이 아닌 완전 투명한 영역에 대해서는 문제 될 것이 없겠지요.. 완전 투명한 영역이면 흰색으로 체워져 있을것이고 그렇다 해도 그 영역을 완전 투명으로 찍어버릴꺼니 문제가 안되겠지만.. 반투명의 경우에는 어쨋든 다른 이미지가 되서 저장되는 문제가 됩니다.

이상입니다.

많이 찾는 글들...
  1. Ghostscript/GhostPCL 설치 안내
  2. 파일 다운로드가 차단되는 경우
  3. 내 컴퓨터의 32비트,64비트 여부 아는법
  4. 'Windows의 PC 보호' 문제
  5. 모두의 프린터 실행후 환경설정창 뜨지 않고 무반응
  6. Ghostscript PDF변환 불가. Can't load Ghostscript DLL
  7. 대법원 인터넷 등기소, 전자소송, 경매,전자공탁등 대법원계열 사이트관련
  8. 모두의 프린터 사용후 네트워크 장애가 발생하는경우. (대법원 인터넷등기소 등)
  9. 출력시 모두의 프린터가 강제종료 되는 경우.'지원하지 않는 PDF또는 가상 프린터입니다.'
  10. 오픽(OPic), 연결상태 확인 불가 프린터, 등록되지 않은 프린터(MarkAny e-PageSAFER)
  11. YBM 토익성적표 관련(정상적인 프린터로 출력을 진행하시기 바랍니다)
  12. 리포트뷰어(ReportViewer) 관련(특수목적프린터, 문서변환 프로그램을 제거해주세요)
  13. 인터넷증명발급센터 서트피아(Certpia) 관련 안내
  14. 인강사이트 관련 - 출력에 매우 오래 걸림, PDF파일 버벅거림, PDF여는데 오래걸림 등등
  15. '잘못된 프린터 데이터를 수신하였습니다.' 문제
  16. MS서피스, 삼성 갤럭시북 등 ARM기반 랩탑, '잘못된 프린터 데이터를 수신하였습니다.' 문제

모두의프린터에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기