Skip to content

Footer in generated pdf should use dd.MM.yyyy HH:mm:ss #1334

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 4, 2025

Conversation

HauklandJ
Copy link
Contributor

@HauklandJ HauklandJ commented Jun 3, 2025

Description

The explicit format string "dd.MM.yyyy HH:mm:ss" bypasses the complex culture-dependent format resolution that the "G" specifier relies on. While the "G" format specifier is supposed to be culture-aware, it can fall back to system defaults when thread culture is not properly set, even when an explicit culture is provided to the method and DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: false

Another solution would be to update the docs on how to set up the dockerfile for pdf footer.

By adding default culture:

// Set .NET default culture
ENV DOTNET_DefaultCulture=nb-NO
ENV DOTNET_DefaultUICulture=nb-NO

This has not been tested though.

Related Issue(s)

  • #{issue number}

Verification

  • Your code builds clean without any errors or warnings
  • Manual testing done (required)
  • Relevant automated test added (if you find this hard, leave it and we'll help out)
  • All tests run green

Documentation

  • User documentation is updated with a separate linked PR in altinn-studio-docs. (if applicable)

@HauklandJ HauklandJ added kind/bug Something isn't working backport-ignore This PR is a new feature and should not be cherry-picked onto release branches labels Jun 3, 2025
@HauklandJ
Copy link
Contributor Author

/publish

Copy link

github-actions bot commented Jun 3, 2025

PR release:

⚙️ Building...
✅ Done!

@HauklandJ HauklandJ added bugfix Label Pull requests with bugfix. Used when generation releasenotes and removed kind/bug Something isn't working labels Jun 3, 2025
@ivarne
Copy link
Member

ivarne commented Jun 4, 2025

https://docs.altinn.studio//nb/altinn-studio/reference/ux/pdf/#forutsetninger
image

Is it really better to fallback to UTC (in tt02/prod) than to error when someone doesn't read the documentation?

@HauklandJ
Copy link
Contributor Author

/publish

Copy link

github-actions bot commented Jun 4, 2025

PR release:

⚙️ Building...
✅ Done!

@HauklandJ
Copy link
Contributor Author

HauklandJ commented Jun 4, 2025

Testing done:

first test:

Added loggin to the GetFooterContent method:

        _logger.LogInformation("--------------------------------------------------------");
        _logger.LogInformation($"Current culture: {CultureInfo.CurrentCulture.Name}");
        _logger.LogInformation($"Current UI culture: {CultureInfo.CurrentUICulture.Name}");
        _logger.LogInformation(
            $"DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: {Environment.GetEnvironmentVariable("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT")}"
        );

        _logger.LogInformation($"Successfully created nb-NO culture");

        // string dateGenerated = now.ToString("G", nbCulture);
        string dateGenerated = now.ToString("dd.MM.yyyy HH:mm:ss", new CultureInfo("nb-NO"));
        _logger.LogInformation($"Generated date with nb-NO culture: {dateGenerated}");

Output:

Current culture:
Current UI culture:
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: false
Successfully created nb-NO culture

However, the footer reads:

6/4/2025 11:22:39 ID:2ff52303ad47

successfully create the nb-NO culture, but it seems like something is overriding it or the format string "G" is not behaving as expected.

Tests:

  using System.Globalization;
  using Xunit.Abstractions;
  
  namespace Altinn.App.Core.Tests.Helpers;
  
  public class DateFormattingBehaviorTests
  {
      private readonly ITestOutputHelper _output;
      private readonly DateTime _testDate = new DateTime(2025, 6, 4, 11, 22, 39);
  
      public DateFormattingBehaviorTests(ITestOutputHelper output)
      {
          _output = output;
      }
  
      [Fact]
      public void TestGFormatWithNorwegianCulture_WithEmptyThreadCulture()
      {
          // Arrange - Simulate container environment with empty thread culture
          var originalCulture = Thread.CurrentThread.CurrentCulture;
          var originalUICulture = Thread.CurrentThread.CurrentUICulture;
  
          try
          {
              // Set thread culture to invariant (simulating container behavior)
              Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
              Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
  
              // Act
              var gFormatResult = _testDate.ToString("G", new CultureInfo("nb-NO"));
              var explicitFormatResult = _testDate.ToString("dd.MM.yyyy HH:mm:ss", new CultureInfo("nb-NO"));
  
              // Log results
              _output.WriteLine($"Thread Culture: {Thread.CurrentThread.CurrentCulture.Name}");
              _output.WriteLine($"Thread UI Culture: {Thread.CurrentThread.CurrentUICulture.Name}");
              _output.WriteLine($"G Format Result: {gFormatResult}");
              _output.WriteLine($"Explicit Format Result: {explicitFormatResult}");
  
              // Assert - Check if G format behaves as expected
              Assert.Contains(".", gFormatResult); // Norwegian uses dots as date separators
              Assert.DoesNotContain("/", gFormatResult); // Should not contain US-style slashes
  
              // The explicit format should always work correctly
              Assert.Equal("04.06.2025 11:22:39", explicitFormatResult);
          }
          finally
          {
              // Restore original culture
              Thread.CurrentThread.CurrentCulture = originalCulture;
              Thread.CurrentThread.CurrentUICulture = originalUICulture;
          }
      }
  
      [Fact]
      public void TestGFormatWithNorwegianCulture_WithUSThreadCulture()
      {
          // Arrange - Simulate environment with US thread culture
          var originalCulture = Thread.CurrentThread.CurrentCulture;
          var originalUICulture = Thread.CurrentThread.CurrentUICulture;
  
          try
          {
              // Set thread culture to US (potential interference)
              Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US");
              Thread.CurrentThread.CurrentUICulture = new CultureInfo("en-US");
  
              // Act
              var gFormatResult = _testDate.ToString("G", new CultureInfo("nb-NO"));
              var explicitFormatResult = _testDate.ToString("dd.MM.yyyy HH:mm:ss", new CultureInfo("nb-NO"));
  
              // Log results
              _output.WriteLine($"Thread Culture: {Thread.CurrentThread.CurrentCulture.Name}");
              _output.WriteLine($"Thread UI Culture: {Thread.CurrentThread.CurrentUICulture.Name}");
              _output.WriteLine($"G Format Result: {gFormatResult}");
              _output.WriteLine($"Explicit Format Result: {explicitFormatResult}");
  
              // Assert
              Assert.Contains(".", gFormatResult); // Should still use Norwegian formatting
              Assert.DoesNotContain("/", gFormatResult);
              Assert.Equal("04.06.2025 11:22:39", explicitFormatResult);
          }
          finally
          {
              Thread.CurrentThread.CurrentCulture = originalCulture;
              Thread.CurrentThread.CurrentUICulture = originalUICulture;
          }
      }
  
      [Theory]
      [InlineData("nb-NO", "04.06.2025 11:22:39")]
      [InlineData("en-US", "6/4/2025 11:22:39 AM")]
      [InlineData("en-GB", "04/06/2025 11:22:39")]
      [InlineData("de-DE", "04.06.2025 11:22:39")]
      [InlineData("fr-FR", "04/06/2025 11:22:39")]
      public void TestExplicitFormatConsistency(string cultureName, string expectedExplicitFormat)
      {
          // Arrange
          var culture = new CultureInfo(cultureName);
          var explicitFormat = cultureName == "en-US" ? "M/d/yyyy h:mm:ss tt" : "dd.MM.yyyy HH:mm:ss";
  
          // Act
          var gFormatResult = _testDate.ToString("G", culture);
          var explicitFormatResult = _testDate.ToString(explicitFormat, culture);
  
          // Log results
          _output.WriteLine($"Culture: {cultureName}");
          _output.WriteLine($"G Format: {gFormatResult}");
          _output.WriteLine($"Explicit Format: {explicitFormatResult}");
          _output.WriteLine($"Expected: {expectedExplicitFormat}");
          _output.WriteLine("---");
  
          // Assert - Explicit format should match expected
          Assert.Equal(expectedExplicitFormat, explicitFormatResult);
      }
  
      [Fact]
      public void TestCultureCreationAndProperties()
      {
          // Test culture creation behavior similar to your logs
          var originalCulture = Thread.CurrentThread.CurrentCulture;
          var originalUICulture = Thread.CurrentThread.CurrentUICulture;
  
          try
          {
              // Simulate container environment
              Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
              Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
  
              // Test Norwegian culture creation
              CultureInfo nbCulture;
              var cultureCreationSucceeded = false;
  
              try
              {
                  nbCulture = new CultureInfo("nb-NO");
                  cultureCreationSucceeded = true;
                  _output.WriteLine("Successfully created nb-NO culture");
              }
              catch (CultureNotFoundException ex)
              {
                  _output.WriteLine($"Failed to create nb-NO culture: {ex.Message}");
                  nbCulture = CultureInfo.InvariantCulture;
              }
  
              // Log culture information
              _output.WriteLine($"Current culture: '{Thread.CurrentThread.CurrentCulture.Name}'");
              _output.WriteLine($"Current UI culture: '{Thread.CurrentThread.CurrentUICulture.Name}'");
              _output.WriteLine($"Norwegian culture name: {nbCulture.Name}");
              _output.WriteLine($"Norwegian culture display name: {nbCulture.DisplayName}");
  
              // Test date formatting with created culture
              var gFormat = _testDate.ToString("G", nbCulture);
              var explicitFormat = _testDate.ToString("dd.MM.yyyy HH:mm:ss", nbCulture);
  
              _output.WriteLine($"G format result: {gFormat}");
              _output.WriteLine($"Explicit format result: {explicitFormat}");
  
              // Assertions
              Assert.True(cultureCreationSucceeded, "Should be able to create nb-NO culture");
              Assert.Equal("nb-NO", nbCulture.Name);
              Assert.Equal("04.06.2025 11:22:39", explicitFormat);
          }
          finally
          {
              Thread.CurrentThread.CurrentCulture = originalCulture;
              Thread.CurrentThread.CurrentUICulture = originalUICulture;
          }
      }
  
      [Fact]
      public void TestFormatSpecifierBehaviorComparison()
      {
          // Test different format specifiers with Norwegian culture
          var nbCulture = new CultureInfo("nb-NO");
  
          var formats = new[]
          {
              ("G", "General date/time pattern (short time)"),
              ("g", "General date/time pattern (long time)"),
              ("F", "Full date/time pattern (long time)"),
              ("f", "Full date/time pattern (short time)"),
              ("dd.MM.yyyy HH:mm:ss", "Explicit Norwegian format"),
          };
  
          _output.WriteLine("Format Specifier Comparison:");
          _output.WriteLine("============================");
  
          foreach (var (format, description) in formats)
          {
              try
              {
                  var result = _testDate.ToString(format, nbCulture);
                  _output.WriteLine($"{format, -20} | {description, -35} | {result}");
  
                  // Test that explicit format produces expected result
                  if (format == "dd.MM.yyyy HH:mm:ss")
                  {
                      Assert.Equal("04.06.2025 11:22:39", result);
                  }
              }
              catch (Exception ex)
              {
                  _output.WriteLine($"{format, -20} | {description, -35} | ERROR: {ex.Message}");
              }
          }
      }
  
      [Fact]
      public void TestEnvironmentVariableSimulation()
      {
          // Test the effect of DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
          var globalizationInvariant = Environment.GetEnvironmentVariable("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT");
  
          _output.WriteLine($"DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: {globalizationInvariant ?? "not set"}");
  
          // Test culture availability
          var availableCultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
          var norwegianCultures = Array.FindAll(
              availableCultures,
              c => c.Name.StartsWith("nb") || c.Name.StartsWith("no")
          );
  
          _output.WriteLine($"Total available cultures: {availableCultures.Length}");
          _output.WriteLine("Norwegian-related cultures found:");
  
          foreach (var culture in norwegianCultures)
          {
              _output.WriteLine($"  - {culture.Name}: {culture.DisplayName}");
          }
  
          Assert.True(norwegianCultures.Length > 0, "Should find at least one Norwegian culture");
      }
  
      [Fact]
      public void TestActualPdfServiceScenario()
      {
          // Simulate the exact scenario from your PdfService
          var originalCulture = Thread.CurrentThread.CurrentCulture;
          var originalUICulture = Thread.CurrentThread.CurrentUICulture;
  
          try
          {
              // Simulate empty thread culture (as seen in your logs)
              Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
              Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
  
              // Simulate timezone conversion (Europe/Oslo)
              TimeZoneInfo timeZone;
              try
              {
                  timeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Oslo");
              }
              catch (TimeZoneNotFoundException)
              {
                  timeZone = TimeZoneInfo.Utc;
                  _output.WriteLine("Europe/Oslo timezone not found, using UTC");
              }
  
              var utcTime = new DateTime(2025, 6, 4, 9, 22, 39, DateTimeKind.Utc); // UTC time
              var norwayTime = TimeZoneInfo.ConvertTime(utcTime, timeZone);
  
              // Test both approaches
              var gFormatResult = norwayTime.ToString("G", new CultureInfo("nb-NO"));
              var explicitFormatResult = norwayTime.ToString("dd.MM.yyyy HH:mm:ss", new CultureInfo("nb-NO"));
  
              _output.WriteLine($"UTC Time: {utcTime}");
              _output.WriteLine($"Norway Time: {norwayTime}");
              _output.WriteLine($"G Format: {gFormatResult}");
              _output.WriteLine($"Explicit Format: {explicitFormatResult}");
  
              // The explicit format should always work
              Assert.Matches(@"\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}:\d{2}", explicitFormatResult);
  
              // Check if G format contains Norwegian-style separators
              var containsNorwegianSeparators = gFormatResult.Contains(".") && !gFormatResult.Contains("/");
              _output.WriteLine($"G format uses Norwegian separators: {containsNorwegianSeparators}");
          }
          finally
          {
              Thread.CurrentThread.CurrentCulture = originalCulture;
              Thread.CurrentThread.CurrentUICulture = originalUICulture;
          }
      }
  }
  
  // Helper class to test culture behavior in different scenarios
  public static class CultureTestHelpers
  {
      public static void SimulateContainerEnvironment(Action testAction)
      {
          var originalCulture = Thread.CurrentThread.CurrentCulture;
          var originalUICulture = Thread.CurrentThread.CurrentUICulture;
  
          try
          {
              Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
              Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
              testAction();
          }
          finally
          {
              Thread.CurrentThread.CurrentCulture = originalCulture;
              Thread.CurrentThread.CurrentUICulture = originalUICulture;
          }
      }
  
      public static string GetDetailedCultureInfo(CultureInfo culture)
      {
          return $"Name: {culture.Name}, "
              + $"DisplayName: {culture.DisplayName}, "
              + $"TwoLetterISOLanguageName: {culture.TwoLetterISOLanguageName}, "
              + $"IsNeutralCulture: {culture.IsNeutralCulture}";
      }
  }

Results:

TestGFormatWithNorwegianCulture_WithEmptyThreadCulture passed
TestGFormatWithNorwegianCulture_WithUSThreadCulture passed
TestCultureCreationAndProperties passed
TestFormatSpecifierBehaviorComparison passed
TestEnvironmentVariableSimulation passed
TestActualPdfServiceScenario passed
TestExplicitFormatConsistency failed
de-DE passed
nb-NO passed
en-US passed
en-GB failed
fr-FR failed

Conclusion:

Local Environment:

.NET culture formatting works correctly
Norwegian culture creation succeeds
"G" format specifier produces correct Norwegian dates
Some cultures still have issues (en-GB, fr-FR)

Next test:
Using explicit date format in tt02

@HauklandJ
Copy link
Contributor Author

/publish

Copy link

github-actions bot commented Jun 4, 2025

PR release:

⚙️ Building...
✅ Done!

@HauklandJ
Copy link
Contributor Author

Explicit date format:

The explicit format string "dd.MM.yyyy HH:mm:ss" bypasses the complex culture-dependent format resolution that the "G" specifier relies on. While the "G" format specifier is supposed to be culture-aware, it can fall back to system defaults when thread culture is not properly set, even when an explicit culture is provided to the method.

The container logs confirmed the fix worked:
Generated date with nb-NO culture: 04.06.2025 12:35:41
Footer content: 04.06.2025 12:35:41
And the final PDF footer displayed the correct Norwegian format: 04.06.2025 12:35:41 ID:fac3fbcde98e

@HauklandJ HauklandJ marked this pull request as ready for review June 4, 2025 10:43
@HauklandJ HauklandJ linked an issue Jun 4, 2025 that may be closed by this pull request
@HauklandJ HauklandJ changed the title fallback to invariant if culture not found Footer in generated pdf should use dd.MM.yyyy HH:mm:ss Jun 4, 2025
Copy link
Member

@bjosttveit bjosttveit left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, but are seconds really necessary?

Copy link

sonarqubecloud bot commented Jun 4, 2025

Copy link

sonarqubecloud bot commented Jun 4, 2025

Please retry analysis of this Pull-Request directly on SonarQube Cloud

@HauklandJ HauklandJ merged commit 50e5775 into main Jun 4, 2025
12 checks passed
@HauklandJ HauklandJ deleted the support-invariant-culture branch June 4, 2025 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
backport-ignore This PR is a new feature and should not be cherry-picked onto release branches bugfix Label Pull requests with bugfix. Used when generation releasenotes
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Wrong date-format for timestamp in the generated pdf
3 participants