2

I have been having quite a time trying to get pixel perfect straight lines on a canvas element. I have tried a number of solutions that have been proposed on other stackoverflow issues and on an issue opened on the WebGL github repo. I'm still not sure what I'm missing. I'm trying to draw a ruler (like a word processor ruler) with a triangle that represents current indentation. A paragraph of text below this ruler should have the left indent of the paragraph match the position of the triangle on the ruler. I have succeeded in doing so, but not all of the vertical lines on the ruler are perfectly sharp, some are slightly thicker than others. Here is my test sandbox for this case. The only solution that worked very nicely (as regards sharpness) on my screen (which has 1.25 devicePixelRatio) does not however work (as regards ruler size and paragraph alignment with the ruler) on screens of different dPR. The solution that works (as regards ruler size and paragraph alignment with the ruler) for all screens I have tested on does not work (as regards line sharpness) on any of the screens I have tested on. Any ideas how I can get the vertical lines to be perfectly sharp? I could certainly live with them not being perfect, but I believe in the end it makes for a better user experience when generated graphic ui interfaces are pixel perfect.

I don't think it's useful to post all of the code from the jsbin example here but I'm fundamentally doing what is recommened on the mozilla website:

//options.rulerLength == 6.25, options.initialPadding == 35
options.canvasWidth = (options.rulerLength * 96 * window.devicePixelRatio) + (options.initialPadding*2);
canvas.style.width = options.canvasWidth + 'px';
canvas.style.height = options.canvasHeight + 'px';

canvas.width = options.canvasWidth * window.devicePixelRatio;
canvas.height = options.canvasHeight * window.devicePixelRatio;
context.scale(window.devicePixelRatio,window.devicePixelRatio);

In my initial quest I had naively set out to make a ruler with exact real life unit size, until researching I found that this is currently impossible (and considered not important for web standards) so I gave up on trying to get the ruler to match real life unit inches or centimeters (since this UI is going to be part of an add-on for Google Docs, I even tried measuring the ruler at the top of a Google Docs document and in fact it does not correpond to real unit inches). And so I gave the title "the CSS inch ruler", I have abandoned attempting a real unit inch ruler and have accepted to create a "CSS unit inch" ruler. But my main priority now, other than keeping the paragraph and the ruler in sync, is that of getting pixel sharp lines...

Choosing "INCHES" and "Greater Draw Precision (on my 1.25 dPR monitor)" from the above mentioned test gives me this result: image But the soluton only works on my 1.25 dPR monitor, and only when drawing "INCHES".

Choosing the "Recommended Draw" (solution from the Mozilla website) makes the paragraph line up nicely with the ruler on all displays but gives me this kind of vertical lines: image

I have tried translating the context by 0.5, I've tried translating by dPR, I've tried rounding the x position of the line that I need to draw, nothing seems to work. Some lines are sharp, others aren't. (I've only been testing on Chrome btw.) I have tried the 3 step process mentioned towards the end of this comment, still didn't work with and without rounding, with and without translating...

I haven't tried getBoundingClientRect as mentioned in this comment, I'm not sure how I would implement it, if that could be a solution and anyone can help I would be grateful. Feel free to touch up the code on the jsbin sandbox test.

2 Answers 2

1

I firmly believe you cannot get sharp lines because you are drawing them using the path API. I have only gotten pixel perfection by using the pixel manipulation API.

Sign up to request clarification or add additional context in comments.

Comments

0

So coming back to this four years later, I decided to give it a go again. I started creating specific pixel adjustments for every single line until it looked right, then looked at the pattern of adjustments. After a lot of trial and error, I finally came up with a sequence of adjustments that works on my screen anyways, I'll have to test it out on a few other screens, but here's the repeating pattern of adjustments that did the trick in this example:

const pattern = [0.6, 0, 0.2, 0.4];

With a generator function I'm able to create a continuous sequence that I can apply as an adjustment (at least for inches; will have to do a little more testing for centimeters):

  const generateAdjustedSequence = (function* () {
    const pattern = [0.6, 0, 0.2, 0.4];
    let index = 0;
  
    while (true) {
      yield pattern[index % pattern.length];
      index++;
    }
  })();

The snippet is best viewed at full page :)

UPDATE 10/07/2024: I found a sequence that works well on my monitor for Centimeters:

const pattern = [-0.2, 0, 0.2, 0.4];

UPDATE 12/07/2024: I found an even better sequence that works well on my monitor for Centimeters, I now have pixel perfect vertical lines all along the ruler (I did have to do some Math rounding on the Inches to Centimeter conversion to get this to work correctly):

const pattern = [
  -0.2, 0.05, 0.3, 0.55, 0.8, 1.05, 1.3, 1.55, 1.8,
  -1.95, -1.7, -1.45, -1.2, -0.95, -0.7, -0.45
];

I would be interesting in knowing whether others are seeing pixel perfect alignments of the vertical lines.

let rulerOptions = {
  rulerLength: 6.25, //in inches
  convertToCM: false,
  canvasWidth: 800, //the canvasWidth will be calculated as a conversion of 6.25 inches to css pixels + initialPadding
  canvasHeight: 20,
  canvas: document.getElementById('previewRuler'),
  initialPadding: 35,
  lineWidth: 0.5,
  strokeStyle: '#000',
  font: 'bold 10px Arial',
  tabPos: 0
};
let inchLabel = 'Current tabulation position (1/4 inch increments):';
let cmLabel = 'Current tabulation position (1/2 cm increments):';

const getPixelRatioVals = function(convertToCM, rulerLength) {
    let inchesToCM = 2.54,
      dpr = window.devicePixelRatio, // 1.5625
      ppi = (96 * dpr) / 100, // 1.5 works great for a fullHD monitor with dPR 1.25
      dpi = 96 * ppi, // 144 works great for my fullHD monitor with dPR 1.25
      dpiA = 96 * dpr, // 150
      //for inches we will draw a line every eigth of an inch
      drawInterval = 0.125;
    if (convertToCM) {
      ppi = Math.round(ppi / inchesToCM);
      dpi = Math.round(dpi / inchesToCM);
      dpiA = Math.round(dpiA / inchesToCM);
      rulerLength = Math.round(rulerLength * inchesToCM);
      //for centimeters we will draw a line every quarter centimeter
      drawInterval = 0.25;
    }
    return {
      inchesToCM: inchesToCM,
      dpr: dpr,
      ppi: ppi,
      dpi: dpi,
      dpiA: dpiA,
      rulerLength: rulerLength,
      drawInterval: drawInterval
    };
  },
  triangleAt = function(x, options) {
    let context = options.context,
      pixelRatioVals = options.pixelRatioVals,
      initialPadding = options.initialPadding;

    let xPosA = x * pixelRatioVals.dpiA;
    context.lineWidth = 0.5;
    context.fillStyle = "#4285F4";
    context.beginPath();
    context.moveTo(initialPadding + xPosA - 6, 11);
    context.lineTo(initialPadding + xPosA + 6, 11);
    context.lineTo(initialPadding + xPosA, 18);
    context.closePath();
    context.stroke();
    context.fill();
  },
  drawRuler = function(userOptions) {
    if (typeof userOptions !== 'object') {
      alert('bad options data');
      return false;
    }
    let options = jQuery.extend({}, {
      rulerLength: 6.25, //in inches
      convertToCM: false,
      canvasWidth: 800,
      canvasHeight: 20,
      canvas: document.getElementById('previewRuler'),
      initialPadding: 35,
      lineWidth: 0.5,
      strokeStyle: '#000',
      font: 'bold 10px Arial',
      tabPos: 0.25
    }, userOptions);
    let context = options.canvas.getContext('2d'),
      pixelRatioVals = getPixelRatioVals(options.convertToCM, options.rulerLength),
      canvas = options.canvas;
    options.context = context;
    options.pixelRatioVals = pixelRatioVals;
    options.canvasWidth = (options.rulerLength * 96 * pixelRatioVals.dpr) + (options.initialPadding * 2);
    canvas.style.width = options.canvasWidth + 'px';
    canvas.style.height = options.canvasHeight + 'px';
    canvas.width = options.canvasWidth * pixelRatioVals.dpr;
    canvas.height = options.canvasHeight * pixelRatioVals.dpr;
    context.scale(pixelRatioVals.dpr, pixelRatioVals.dpr);

    context.lineWidth = options.lineWidth;
    context.strokeStyle = options.strokeStyle;
    context.font = options.font;

    context.beginPath();
    context.moveTo(options.initialPadding, 1);
    context.lineTo(options.initialPadding + pixelRatioVals.rulerLength * pixelRatioVals.dpiA, 1);
    context.stroke();

    let currentWholeNumber = 0;
    let offset = 2; //slight offset to center numbers
    const snapPixels = options.convertToCM
      ? (function* () {
          const pattern = [
            -0.2, 0.05, 0.3, 0.55, 0.8, 1.05, 1.3, 1.55, 1.8, -1.95, -1.7,
            -1.45, -1.2, -0.95, -0.7, -0.45,
          ];
          let index = 0;

          while (true) {
            yield pattern[index % pattern.length];
            index++;
          }
        })()
      : (function* () {
          const pattern = [0.6, 0, 0.2, 0.4];
          let index = 0;

          while (true) {
            yield pattern[index % pattern.length];
            index++;
          }
        })();

    for (let interval = 0; interval <= pixelRatioVals.rulerLength; interval += pixelRatioVals.drawInterval) {
      let xPosA = interval * pixelRatioVals.dpiA + snapPixels.next().value;
      if (interval == Math.floor(interval) && interval > 0) {
        if (currentWholeNumber + 1 == 10) {
          offset += 4;
        } //compensate number centering when two digits
        context.fillText(++currentWholeNumber, options.initialPadding + xPosA - offset, 14);
      } else if (interval == Math.floor(interval) + 0.5) {
        context.beginPath();
        context.moveTo(options.initialPadding + xPosA, 15);
        context.lineTo(options.initialPadding + xPosA, 5);
        context.closePath();
        context.stroke();
      } else {
        context.beginPath();
        context.moveTo(options.initialPadding + xPosA, 10);
        context.lineTo(options.initialPadding + xPosA, 5);
        context.closePath();
        context.stroke();
      }
    }
    let xPosB = options.tabPos;
    if (options.convertToCM) {
      xPosB *= 2;
    }
    triangleAt(xPosB, options);
  };

switch (rulerOptions.convertToCM) {
  case true:
    jQuery('input#useCM').prop("checked", true);
    break;
  case false:
    jQuery('input#useInches').prop("checked", true);
    break;
}
jQuery('.controlgroup').controlgroup({
  "direction": "vertical"
});
jQuery('#tabPositionSlider').slider({
  min: 0,
  max: rulerOptions.rulerLength,
  value: rulerOptions.tabPos,
  step: 0.25,
  slide: function(event, ui) {
    jQuery('#currentTabPosition').val(ui.value);
    rulerOptions.tabPos = rulerOptions.convertToCM ? parseFloat(ui.value / 2) : parseFloat(ui.value);
    drawRuler(rulerOptions);
    let pixelRatioVals = getPixelRatioVals(rulerOptions.convertToCM, rulerOptions.rulerLength);
    let paddingLeft = rulerOptions.initialPadding + (ui.value * pixelRatioVals.dpiA);
    jQuery("#dummyText").css({
      "padding-left": paddingLeft + "px"
    });
  }
});
jQuery('label[for=currentTabPosition]').text((rulerOptions.convertToCM ? cmLabel : inchLabel));
jQuery('#currentTabPosition').val(rulerOptions.tabPos);
jQuery('.controlgroup input').on('change', function() {
  if (this.checked) {
    let curSliderVal, pixelRatioVals, paddingLeft;
    switch (this.value) {
      case "useCM":
        rulerOptions.convertToCM = true;
        jQuery('label[for=currentTabPosition]').text(cmLabel);
        curSliderVal = jQuery("#tabPositionSlider").slider("value");
        jQuery("#tabPositionSlider").slider("option", "max", rulerOptions.rulerLength * 2.54);
        jQuery("#tabPositionSlider").slider("option", "step", 0.5);
        jQuery("#tabPositionSlider").slider("value", curSliderVal * 2); //rulerLength *= inchesToCM
        jQuery("#currentTabPosition").val((curSliderVal * 2).toString());
        drawRuler(rulerOptions);
        pixelRatioVals = getPixelRatioVals(rulerOptions.convertToCM, rulerOptions.rulerLength);
        paddingLeft = rulerOptions.initialPadding + (curSliderVal * 2 * pixelRatioVals.dpiA);
        jQuery("#dummyText").css({
          "padding-left": paddingLeft + "px"
        });
        break;
      case "useInches":
        rulerOptions.convertToCM = false;
        jQuery('label[for=currentTabPosition]').text(inchLabel);
        curSliderVal = jQuery("#tabPositionSlider").slider("value");
        jQuery("#tabPositionSlider").slider("option", "max", rulerOptions.rulerLength);
        jQuery("#tabPositionSlider").slider("option", "step", 0.25);
        jQuery("#tabPositionSlider").slider("value", curSliderVal / 2);
        jQuery("#currentTabPosition").val((curSliderVal / 2).toString());
        drawRuler(rulerOptions);
        pixelRatioVals = getPixelRatioVals(rulerOptions.convertToCM, rulerOptions.rulerLength);
        paddingLeft = rulerOptions.initialPadding + (curSliderVal / 2 * pixelRatioVals.dpiA);
        jQuery("#dummyText").css({
          "padding-left": paddingLeft + "px"
        });
        break;
    }
  }
});

let bestWidth = rulerOptions.rulerLength * 96 * window.devicePixelRatio + (rulerOptions.initialPadding * 2); // * window.devicePixelRatio
jQuery('#dummyText').css({
  "width": bestWidth + "px"
});
drawRuler(rulerOptions);
body {
  text-align: center;
  border: 1px solid green;
}

#dummyTextContainer {
  width: 100%;
}

#dummyText {
  text-align: justify;
  width: 800px;
  margin: 0px auto;
  box-sizing: border-box;
  padding-left: 35px;
  padding-right: 35px;
}

div.controlgroup {
  padding: 12px;
  border: 1px groove white;
  font-weight: bold;
  text-align: center;
  width: auto;
  margin: 12px;
}

canvas {
  border: 1px solid Red;
}

div#tabPositionSlider {
  width: 80%;
  margin: 0px auto;
}

span.ui-checkboxradio-icon {
  display: none;
}

.ui-controlgroup-vertical .ui-controlgroup-item {
  text-align: center;
}
<!DOCTYPE html>
<html>

<head>
  <meta name="description" content="css inch ruler">
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
  <link href="https://code.jquery.com/ui/1.12.1/themes/smoothness/jquery-ui.css" rel="stylesheet" type="text/css" />
</head>

<body>
  <h2 id="greeting">THE "CSS INCH" RULER (6.25 css inches or 15.875 css centimeters)</h2>
  <canvas id="previewRuler"></canvas>
  <div id="dummyTextContainer">
    <p id="dummyText">Friends, Romans, countrymen, lend me your ears; I come to bury Caesar, not to praise him. The evil that men do lives after them; The good is oft interred with their bones; So let it be with Caesar. The noble Brutus Hath told you Caesar was ambitious:
      If it were so, it was a grievous fault, And grievously hath Caesar answer’d it. Here, under leave of Brutus and the rest– For Brutus is an honourable man; So are they all, all honourable men– Come I to speak in Caesar’s funeral. He was my friend,
      faithful and just to me: But Brutus says he was ambitious; And Brutus is an honourable man. He hath brought many captives home to Rome Whose ransoms did the general coffers fill: Did this in Caesar seem ambitious? When that the poor have cried,
      Caesar hath wept: Ambition should be made of sterner stuff: Yet Brutus says he was ambitious; And Brutus is an honourable man. You all did see that on the Lupercal I thrice presented him a kingly crown, Which he did thrice refuse: was this ambition?
      Yet Brutus says he was ambitious; And, sure, he is an honourable man. I speak not to disprove what Brutus spoke, But here I am to speak what I do know. You all did love him once, not without cause: What cause withholds you then, to mourn for him?
      O judgment! thou art fled to brutish beasts, And men have lost their reason. Bear with me; My heart is in the coffin there with Caesar, And I must pause till it come back to me.</p>
  </div>
  <div id="controlsContainer">
    <div class="controlgroup">
      <label><input type="radio" value="useInches" name="unit" id="useInches">INCHES</label>
      <label><input type="radio" value="useCM" name="unit" id="useCM">CENTIMETERS</label>
    </div>
    <div id="tabPositionSlider"></div>
    <p>
      <label for="currentTabPosition">Current tabulation position (1/4 inch increments):</label>
      <input type="text" id="currentTabPosition" readonly style="border:0; color:#f6931f; background-color:transparent; font-weight:bold;">
    </p>

  </div>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
  <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
</body>

</html>

1 Comment

I can confirm that this solution does NOT work for screens with devicePixelRatio=1, but only for screens with devicePixelRation=1.25.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.